diff --git a/.github/workflows/ci-post-merge.yml b/.github/workflows/ci-post-merge.yml index cfdb53daa..af7a59702 100644 --- a/.github/workflows/ci-post-merge.yml +++ b/.github/workflows/ci-post-merge.yml @@ -49,7 +49,7 @@ jobs: toolchain: ${{ matrix.version.version }} - name: Install just, cargo-hack, cargo-nextest, cargo-ci-cache-clean - uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25 + uses: taiki-e/install-action@f92912fad184299a31e22ad070a5059fd07d4f59 # v2.68.7 with: tool: just,cargo-hack,cargo-nextest,cargo-ci-cache-clean @@ -83,7 +83,7 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1.15.2 - name: Install just, cargo-hack - uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25 + uses: taiki-e/install-action@f92912fad184299a31e22ad070a5059fd07d4f59 # v2.68.7 with: tool: just,cargo-hack diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f8f0695c..66b65f723 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: toolchain: ${{ matrix.version.version }} - name: Install just, cargo-hack, cargo-nextest, cargo-ci-cache-clean - uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25 + uses: taiki-e/install-action@f92912fad184299a31e22ad070a5059fd07d4f59 # v2.68.7 with: tool: just,cargo-hack,cargo-nextest,cargo-ci-cache-clean @@ -79,7 +79,7 @@ jobs: run: just check-default - name: tests - timeout-minutes: 60 + timeout-minutes: 30 run: just test - name: CI cache clean @@ -101,7 +101,7 @@ jobs: toolchain: nightly - name: tests (io-uring) - timeout-minutes: 60 + timeout-minutes: 30 run: > sudo bash -c "ulimit -Sl 512 && ulimit -Hl 512 && PATH=$PATH:/usr/share/rust/.cargo/bin && RUSTUP_TOOLCHAIN=stable cargo test --lib --tests -p=actix-files --all-features" @@ -117,7 +117,7 @@ jobs: toolchain: nightly - name: Install just - uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25 + uses: taiki-e/install-action@f92912fad184299a31e22ad070a5059fd07d4f59 # v2.68.7 with: tool: just diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index e5759292b..dcebffae8 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -24,7 +24,7 @@ jobs: components: llvm-tools - name: Install just, cargo-llvm-cov, cargo-nextest - uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25 + uses: taiki-e/install-action@f92912fad184299a31e22ad070a5059fd07d4f59 # v2.68.7 with: tool: just,cargo-llvm-cov,cargo-nextest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 85f4434a0..bfbcb4044 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -77,12 +77,12 @@ jobs: toolchain: ${{ vars.RUST_VERSION_EXTERNAL_TYPES }} - name: Install just - uses: taiki-e/install-action@f176c07a0a40cbfdd08ee9aa8bf1655701d11e69 # v2.67.25 + uses: taiki-e/install-action@f92912fad184299a31e22ad070a5059fd07d4f59 # v2.68.7 with: tool: just - name: Install cargo-check-external-types - uses: taiki-e/cache-cargo-install-action@34ce5120836e5f9f1508d8713d7fdea0e8facd6f # v3.0.1 + uses: taiki-e/cache-cargo-install-action@2bfc3cedaf2ee5e7fa5d0ae034ccd5fb50cf8e1f # v3.0.2 with: tool: cargo-check-external-types diff --git a/Cargo.lock b/Cargo.lock index ac784f41e..6117d1867 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,36 +2,13 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "actix" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de7fa236829ba0841304542f7614c42b80fca007455315c45c785ccfa873a85b" -dependencies = [ - "actix-rt", - "bitflags 2.10.0", - "bytes", - "crossbeam-channel", - "futures-core", - "futures-sink", - "futures-task", - "futures-util", - "log", - "once_cell", - "parking_lot", - "pin-project-lite", - "smallvec", - "tokio", - "tokio-util", -] - [[package]] name = "actix-codec" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "futures-core", "futures-sink", @@ -53,7 +30,7 @@ dependencies = [ "actix-test", "actix-utils", "actix-web", - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "derive_more", "env_logger", @@ -72,7 +49,7 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.11.2" +version = "3.12.0" dependencies = [ "actix-codec", "actix-http-test", @@ -83,8 +60,9 @@ dependencies = [ "actix-utils", "actix-web", "async-stream", + "awc", "base64 0.22.1", - "bitflags 2.10.0", + "bitflags 2.11.0", "brotli", "bytes", "bytestring", @@ -225,7 +203,7 @@ dependencies = [ [[package]] name = "actix-router" -version = "0.5.3" +version = "0.5.4" dependencies = [ "bytestring", "cfg-if", @@ -346,7 +324,7 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.12.1" +version = "4.13.0" dependencies = [ "actix-codec", "actix-files", @@ -404,28 +382,6 @@ dependencies = [ "zstd", ] -[[package]] -name = "actix-web-actors" -version = "4.3.1+deprecated" -dependencies = [ - "actix", - "actix-codec", - "actix-http", - "actix-rt", - "actix-test", - "actix-web", - "awc", - "bytes", - "bytestring", - "env_logger", - "futures-core", - "futures-util", - "mime", - "pin-project-lite", - "tokio", - "tokio-util", -] - [[package]] name = "actix-web-codegen" version = "4.3.0" @@ -612,7 +568,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "awc" -version = "3.8.1" +version = "3.8.2" dependencies = [ "actix-codec", "actix-http", @@ -701,9 +657,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" @@ -1221,9 +1177,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "0.1.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" dependencies = [ "log", "regex", @@ -1231,9 +1187,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ "anstream", "anstyle", @@ -1344,15 +1300,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1361,15 +1317,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -1378,21 +1334,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-test" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5961fb6311645f46e2cdc2964a8bfae6743fd72315eaec181a71ae3eb2467113" +checksum = "32d24b40cb9018c6b0f9d891b74a86a777d5db37972a115016d1150257b1c793" dependencies = [ "futures-core", "futures-executor", @@ -1406,9 +1362,9 @@ dependencies = [ [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", @@ -1416,7 +1372,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1875,7 +1830,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", "redox_syscall 0.7.1", ] @@ -2061,7 +2016,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "foreign-types", "libc", @@ -2164,12 +2119,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkg-config" version = "0.3.32" @@ -2377,7 +2326,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -2386,7 +2335,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -2474,7 +2423,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -2646,7 +2595,7 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation", "core-foundation-sys", "libc", @@ -2900,9 +2849,9 @@ checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" [[package]] name = "tempfile" -version = "3.24.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", "getrandom 0.3.4", @@ -3158,9 +3107,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.11+spec-1.1.0" +version = "1.0.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +checksum = "bbe30f93627849fa362d4a602212d41bb237dc2bd0f8ba0b2ce785012e124220" dependencies = [ "indexmap", "serde_core", @@ -3173,18 +3122,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.0.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.0.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc" dependencies = [ "winnow", ] @@ -3229,9 +3178,9 @@ dependencies = [ [[package]] name = "trybuild" -version = "1.0.115" +version = "1.0.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f614c21bd3a61bad9501d75cbb7686f00386c806d7f456778432c25cf86948a" +checksum = "47c635f0191bd3a2941013e5062667100969f8c4e9cd787c14f977265d73616e" dependencies = [ "glob", "serde", diff --git a/Cargo.toml b/Cargo.toml index 360239d33..905a6ce81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,6 @@ members = [ "actix-multipart-derive", "actix-router", "actix-test", - "actix-web-actors", "actix-web-codegen", "actix-web", "awc", @@ -39,7 +38,6 @@ actix-multipart-derive = { path = "actix-multipart-derive" } actix-router = { path = "actix-router" } actix-test = { path = "actix-test" } actix-web = { path = "actix-web" } -actix-web-actors = { path = "actix-web-actors" } actix-web-codegen = { path = "actix-web-codegen" } awc = { path = "awc" } diff --git a/actix-files/src/range.rs b/actix-files/src/range.rs index 87be8363d..1ef616dcf 100644 --- a/actix-files/src/range.rs +++ b/actix-files/src/range.rs @@ -45,7 +45,7 @@ pub struct HttpRange { impl HttpRange { /// Parses Range HTTP header string as per RFC 2616. /// - /// `header` is HTTP Range header (e.g. `bytes=bytes=0-9`). + /// `header` is HTTP Range header (e.g. `bytes=0-9`). /// `size` is full size of response (file). pub fn parse(header: &str, size: u64) -> Result, ParseRangeErr> { let ranges = diff --git a/actix-http/CHANGES.md b/actix-http/CHANGES.md index c8a2bbd92..9edd7b6f0 100644 --- a/actix-http/CHANGES.md +++ b/actix-http/CHANGES.md @@ -2,17 +2,25 @@ ## Unreleased +- Encode the HTTP/1 `Connection: Upgrade` header in Camel-Case when camel-case header formatting is enabled.[#3953] + +[#3953]: https://github.com/actix/actix-web/pull/3953 + +## 3.12.0 + - Minimum supported Rust version (MSRV) is now 1.88. - Increase default HTTP/2 flow control window sizes. [#3638] - Expose configuration methods to improve upload throughput. [#3638] - Fix truncated body ending without error when connection closed abnormally. [#3067] - Add config/method for `TCP_NODELAY`. [#3918] - Do not compress 206 Partial Content responses. [#3191] +- Fix lingering sockets and client stalls when responding early to dropped chunked request payloads. [#2972] [#3638]: https://github.com/actix/actix-web/issues/3638 [#3067]: https://github.com/actix/actix-web/pull/3067 [#3918]: https://github.com/actix/actix-web/pull/3918 [#3191]: https://github.com/actix/actix-web/issues/3191 +[#2972]: https://github.com/actix/actix-web/issues/2972 ## 3.11.2 diff --git a/actix-http/Cargo.toml b/actix-http/Cargo.toml index 59a763736..6495ebbf1 100644 --- a/actix-http/Cargo.toml +++ b/actix-http/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "actix-http" -version = "3.11.2" +version = "3.12.0" authors = ["Nikolay Kim ", "Rob Ede "] description = "HTTP types and services for the Actix ecosystem" keywords = ["actix", "http", "framework", "async", "futures"] @@ -139,6 +139,7 @@ actix-http-test = { version = "3", features = ["openssl"] } actix-server = "2" actix-tls = { version = "3.4", features = ["openssl", "rustls-0_23-webpki-roots"] } actix-web = "4" +awc = { version = "3", default-features = false, features = ["openssl"] } async-stream = "0.3" criterion = { version = "0.5", features = ["html_reports"] } diff --git a/actix-http/README.md b/actix-http/README.md index 08dae437c..421c904bb 100644 --- a/actix-http/README.md +++ b/actix-http/README.md @@ -5,11 +5,11 @@ [![crates.io](https://img.shields.io/crates/v/actix-http?label=latest)](https://crates.io/crates/actix-http) -[![Documentation](https://docs.rs/actix-http/badge.svg?version=3.11.2)](https://docs.rs/actix-http/3.11.2) +[![Documentation](https://docs.rs/actix-http/badge.svg?version=3.12.0)](https://docs.rs/actix-http/3.12.0) ![Version](https://img.shields.io/badge/rustc-1.88+-ab6000.svg) ![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-http.svg)
-[![dependency status](https://deps.rs/crate/actix-http/3.11.2/status.svg)](https://deps.rs/crate/actix-http/3.11.2) +[![dependency status](https://deps.rs/crate/actix-http/3.12.0/status.svg)](https://deps.rs/crate/actix-http/3.12.0) [![Download](https://img.shields.io/crates/d/actix-http.svg)](https://crates.io/crates/actix-http) [![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) diff --git a/actix-http/src/h1/dispatcher.rs b/actix-http/src/h1/dispatcher.rs index c59be2d50..2ed78cfca 100644 --- a/actix-http/src/h1/dispatcher.rs +++ b/actix-http/src/h1/dispatcher.rs @@ -31,7 +31,7 @@ use crate::{ config::ServiceConfig, error::{DispatchError, ParseError, PayloadError}, service::HttpFlow, - Error, Extensions, OnConnectData, Request, Response, StatusCode, + Error, Extensions, HttpMessage, OnConnectData, Request, Response, StatusCode, }; const LW_BUFFER_SIZE: usize = 1024; @@ -157,6 +157,8 @@ pin_project! { pub(super) state: State, // when Some(_) dispatcher is in state of receiving request payload payload: Option, + // true when current request uses chunked transfer encoding (drainable when payload is dropped) + payload_drainable: bool, messages: VecDeque, head_timer: TimerState, @@ -269,6 +271,7 @@ where state: State::None, payload: None, + payload_drainable: false, messages: VecDeque::new(), head_timer: TimerState::new(config.client_request_deadline().is_some()), @@ -308,7 +311,10 @@ where if self.flags.contains(Flags::READ_DISCONNECT) { false } else if let Some(ref info) = self.payload { - info.need_read(cx) == PayloadStatus::Read + matches!( + info.need_read(cx), + PayloadStatus::Read | PayloadStatus::Dropped + ) } else { true } @@ -387,8 +393,10 @@ where this.state.set(match size { BodySize::None | BodySize::Sized(0) => { let payload_unfinished = this.payload.is_some(); + let drain_payload = this.payload.as_ref().is_some_and(|pl| pl.is_dropped()) + && *this.payload_drainable; - if payload_unfinished { + if payload_unfinished && !drain_payload { this.flags.insert(Flags::SHUTDOWN | Flags::FINISHED); } else { this.flags.insert(Flags::FINISHED); @@ -412,8 +420,10 @@ where this.state.set(match size { BodySize::None | BodySize::Sized(0) => { let payload_unfinished = this.payload.is_some(); + let drain_payload = this.payload.as_ref().is_some_and(|pl| pl.is_dropped()) + && *this.payload_drainable; - if payload_unfinished { + if payload_unfinished && !drain_payload { this.flags.insert(Flags::SHUTDOWN | Flags::FINISHED); } else { this.flags.insert(Flags::FINISHED); @@ -469,8 +479,11 @@ where // all messages are dealt with None => { - // start keep-alive if last request allowed it - this.flags.set(Flags::KEEP_ALIVE, this.codec.keep_alive()); + // start keep-alive only if request payload is fully read/drained + this.flags.set( + Flags::KEEP_ALIVE, + this.payload.is_none() && this.codec.keep_alive(), + ); return Ok(PollResponse::DoNothing); } @@ -522,13 +535,16 @@ where // responding to. We can check to see if we finished reading it // yet, and if not, shutdown the connection. let payload_unfinished = this.payload.is_some(); + let drain_payload = + this.payload.as_ref().is_some_and(|pl| pl.is_dropped()) + && *this.payload_drainable; let not_pipelined = this.messages.is_empty(); // payload stream finished. // set state to None and handle next message this.state.set(State::None); - if not_pipelined && payload_unfinished { + if not_pipelined && payload_unfinished && !drain_payload { this.flags.insert(Flags::SHUTDOWN | Flags::FINISHED); } else { this.flags.insert(Flags::FINISHED); @@ -573,13 +589,16 @@ where // responding to. We can check to see if we finished reading it // yet, and if not, shutdown the connection. let payload_unfinished = this.payload.is_some(); + let drain_payload = + this.payload.as_ref().is_some_and(|pl| pl.is_dropped()) + && *this.payload_drainable; let not_pipelined = this.messages.is_empty(); // payload stream finished. // set state to None and handle next message this.state.set(State::None); - if not_pipelined && payload_unfinished { + if not_pipelined && payload_unfinished && !drain_payload { this.flags.insert(Flags::SHUTDOWN | Flags::FINISHED); } else { this.flags.insert(Flags::FINISHED); @@ -748,12 +767,13 @@ where match this.codec.message_type() { // request has no payload - MessageType::None => {} + MessageType::None => *this.payload_drainable = false, // Request is upgradable. Add upgrade message and break. // Everything remaining in read buffer will be handed to // upgraded Request. MessageType::Stream if this.flow.upgrade.is_some() => { + *this.payload_drainable = false; this.messages.push_back(DispatcherMessage::Upgrade(req)); break; } @@ -768,6 +788,7 @@ where let (sender, payload) = Payload::create(false); *req.payload() = crate::Payload::H1 { payload }; *this.payload = Some(sender); + *this.payload_drainable = req.chunked().unwrap_or(false); } } @@ -797,6 +818,7 @@ where Message::Chunk(None) => { if let Some(mut payload) = this.payload.take() { payload.feed_eof(); + *this.payload_drainable = false; } else { error!("Internal server error: unexpected eof"); this.flags.insert(Flags::READ_DISCONNECT); @@ -999,23 +1021,14 @@ where // // A Request head too large to parse is only checked on `httparse::Status::Partial`. - match this.payload { - // When dispatcher has a payload the responsibility of wake ups is shifted to - // `h1::payload::Payload` unless the payload is needing a read, in which case it - // might not have access to the waker and could result in the dispatcher - // getting stuck until timeout. - // - // Reason: - // Self wake up when there is payload would waste poll and/or result in - // over read. - // - // Case: - // When payload is (partial) dropped by user there is no need to do - // read anymore. At this case read_buf could always remain beyond - // MAX_BUFFER_SIZE and self wake up would be busy poll dispatcher and - // waste resources. - Some(ref p) if p.need_read(cx) != PayloadStatus::Read => {} - _ => cx.waker().wake_by_ref(), + match this.payload.as_ref().map(|p| p.need_read(cx)) { + // Payload consumer is alive but applying backpressure. Wait for its waker. + Some(PayloadStatus::Pause) => {} + + // Consumer dropped means drain/discard mode; keep polling to make progress. + Some(PayloadStatus::Dropped) | Some(PayloadStatus::Read) | None => { + cx.waker().wake_by_ref() + } } return Ok(false); @@ -1029,7 +1042,11 @@ where match tokio_util::io::poll_read_buf(io.as_mut(), cx, this.read_buf) { Poll::Ready(Ok(n)) => { - this.flags.remove(Flags::FINISHED); + // When draining a dropped request payload, keep FINISHED set so the + // disconnect/keep-alive decision can be made once the payload is fully drained. + if !this.payload.as_ref().is_some_and(|pl| pl.is_dropped()) { + this.flags.remove(Flags::FINISHED); + } if n == 0 { return Ok(true); @@ -1244,6 +1261,7 @@ where // disconnect if keep-alive is not enabled if inner_p.flags.contains(Flags::FINISHED) && !inner_p.flags.contains(Flags::KEEP_ALIVE) + && inner_p.payload.is_none() { inner_p.flags.remove(Flags::FINISHED); inner_p.flags.insert(Flags::SHUTDOWN); diff --git a/actix-http/src/h1/dispatcher_tests.rs b/actix-http/src/h1/dispatcher_tests.rs index 49582ad8a..e3a907e5c 100644 --- a/actix-http/src/h1/dispatcher_tests.rs +++ b/actix-http/src/h1/dispatcher_tests.rs @@ -1017,6 +1017,128 @@ async fn handler_drop_payload() { .await; } +// Handler drops request payload without reading it. Server should keep reading and discarding the +// rest of the request body so clients that do not read the response until they've finished +// writing the request (like `requests` in Python) do not deadlock. +// ref. https://github.com/actix/actix-web/issues/2972 +#[actix_rt::test] +async fn handler_drop_payload_drains_body() { + let _ = env_logger::try_init(); + + let mut buf = TestSeqBuffer::new(http_msg( + r" + POST /drop-payload HTTP/1.1 + Transfer-Encoding: chunked + + ", + )); + + let services = HttpFlow::new( + drop_payload_service(), + ExpectHandler, + None::, + ); + + let h1 = Dispatcher::new( + buf.clone(), + services, + ServiceConfig::default(), + None, + OnConnectData::default(), + ); + pin!(h1); + + lazy(|cx| { + assert!(h1.as_mut().poll(cx).is_pending()); + + let mut res = BytesMut::from(buf.take_write_buf().as_ref()); + stabilize_date_header(&mut res); + let res = &res[..]; + + let exp = http_msg( + r" + HTTP/1.1 200 OK + content-length: 15 + date: Thu, 01 Jan 1970 12:34:56 UTC + + payload dropped + ", + ); + + assert_eq!( + res, + exp, + "\nexpected response not in write buffer:\n\ + response: {:?}\n\ + expected: {:?}", + String::from_utf8_lossy(res), + String::from_utf8_lossy(&exp) + ); + }) + .await; + + // stream a body larger than the dispatcher read buffer limit; it should still be drained + // (read + decoded + discarded) without stalling. + for _ in 0..32 { + let data = vec![b'a'; 8192]; + let mut chunk = BytesMut::new(); + chunk.extend_from_slice(format!("{:x}\r\n", data.len()).as_bytes()); + chunk.extend_from_slice(&data); + chunk.extend_from_slice(b"\r\n"); + + buf.extend_read_buf(chunk); + + lazy(|cx| { + assert!(h1.as_mut().poll(cx).is_pending()); + assert!(buf.take_write_buf().is_empty()); + assert!(buf.read_buf().is_empty()); + }) + .await; + } + + // terminating chunk + buf.extend_read_buf(b"0\r\n\r\n"); + + lazy(|cx| { + assert!(h1.as_mut().poll(cx).is_pending()); + assert!(buf.take_write_buf().is_empty()); + assert!(buf.read_buf().is_empty()); + }) + .await; + + // connection should be able to accept another request after draining the previous body + buf.extend_read_buf(http_msg("GET /drop-payload HTTP/1.1")); + + lazy(|cx| { + assert!(h1.as_mut().poll(cx).is_pending()); + + let mut res = BytesMut::from(buf.take_write_buf().as_ref()); + stabilize_date_header(&mut res); + let res = &res[..]; + + let exp = http_msg( + r" + HTTP/1.1 200 OK + content-length: 15 + date: Thu, 01 Jan 1970 12:34:56 UTC + + payload dropped + ", + ); + + assert_eq!( + res, + exp, + "\nexpected response not in write buffer:\n\ + response: {:?}\n\ + expected: {:?}", + String::from_utf8_lossy(res), + String::from_utf8_lossy(&exp) + ); + }) + .await; +} + #[actix_rt::test] async fn allow_half_closed() { let buf = TestSeqBuffer::new(http_msg("GET / HTTP/1.1")); diff --git a/actix-http/src/h1/encoder.rs b/actix-http/src/h1/encoder.rs index 81af7868b..1853843c3 100644 --- a/actix-http/src/h1/encoder.rs +++ b/actix-http/src/h1/encoder.rs @@ -111,7 +111,13 @@ pub(crate) trait MessageType: Sized { // Connection match conn_type { - ConnectionType::Upgrade => dst.put_slice(b"connection: upgrade\r\n"), + ConnectionType::Upgrade => { + if camel_case { + dst.put_slice(b"Connection: Upgrade\r\n") + } else { + dst.put_slice(b"connection: upgrade\r\n") + } + } ConnectionType::KeepAlive if version < Version::HTTP_11 => { if camel_case { dst.put_slice(b"Connection: keep-alive\r\n") @@ -580,6 +586,16 @@ mod tests { assert!(data.contains("Date: date\r\n")); assert!(data.contains("Upgrade-Insecure-Requests: 1\r\n")); + let _ = head.encode_headers( + &mut bytes, + Version::HTTP_11, + BodySize::None, + ConnectionType::Upgrade, + &ServiceConfig::default(), + ); + let data = String::from_utf8(Vec::from(bytes.split().freeze().as_ref())).unwrap(); + assert!(data.contains("Connection: Upgrade\r\n")); + let _ = head.encode_headers( &mut bytes, Version::HTTP_11, diff --git a/actix-http/src/h1/payload.rs b/actix-http/src/h1/payload.rs index 92875a9db..e12c87806 100644 --- a/actix-http/src/h1/payload.rs +++ b/actix-http/src/h1/payload.rs @@ -133,6 +133,11 @@ impl PayloadSender { PayloadStatus::Dropped } } + + #[inline] + pub fn is_dropped(&self) -> bool { + self.inner.strong_count() == 0 + } } #[derive(Debug)] diff --git a/actix-http/src/requests/head.rs b/actix-http/src/requests/head.rs index 9ceb2a20c..ddc9dd98f 100644 --- a/actix-http/src/requests/head.rs +++ b/actix-http/src/requests/head.rs @@ -61,14 +61,15 @@ impl RequestHead { &mut self.headers } - /// Is to uppercase headers with Camel-Case. - /// Default is `false` + /// Returns whether headers should be sent in Camel-Case. + /// + /// Default is `false`. #[inline] pub fn camel_case_headers(&self) -> bool { self.flags.contains(Flags::CAMEL_CASE) } - /// Set `true` to send headers which are formatted as Camel-Case. + /// Sets whether to send headers formatted as Camel-Case. #[inline] pub fn set_camel_case_headers(&mut self, val: bool) { if val { diff --git a/actix-http/tests/test_rustls.rs b/actix-http/tests/test_rustls.rs index 7be595db6..d29e0cbd0 100644 --- a/actix-http/tests/test_rustls.rs +++ b/actix-http/tests/test_rustls.rs @@ -1,5 +1,6 @@ #![cfg(feature = "rustls-0_23")] +extern crate tls_openssl as openssl; extern crate tls_rustls_023 as rustls; use std::{ @@ -22,6 +23,7 @@ use actix_rt::{net::TcpStream as RtTcpStream, pin}; use actix_service::{fn_factory_with_config, fn_service}; use actix_tls::{accept::rustls_0_23::TlsStream, connect::rustls_0_23::webpki_roots_cert_store}; use actix_utils::future::{err, ok, poll_fn}; +use awc::{Client, Connector}; use bytes::{Bytes, BytesMut}; use derive_more::{Display, Error}; use futures_core::{ready, Stream}; @@ -79,6 +81,21 @@ fn tls_config_h2() -> RustlsServerConfig { tls_config_with_alpn(&[H2_ALPN_PROTOCOL]) } +fn h1_client() -> Client { + use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode}; + + let mut builder = SslConnector::builder(SslMethod::tls()).unwrap(); + builder.set_verify(SslVerifyMode::NONE); + builder.set_alpn_protos(b"\x08http/1.1").unwrap(); + + let connector = Connector::new() + .conn_lifetime(Duration::from_secs(0)) + .timeout(Duration::from_millis(30_000)) + .openssl(builder.build()); + + Client::builder().connector(connector).finish() +} + pub fn get_negotiated_alpn_protocol( addr: SocketAddr, client_alpn_protocol: &[u8], @@ -106,21 +123,22 @@ pub fn get_negotiated_alpn_protocol( #[actix_rt::test] async fn h1() -> io::Result<()> { - let srv = test_server(move || { + let mut srv = test_server(move || { HttpService::build() .h1(|_| ok::<_, Error>(Response::ok())) .rustls_0_23(tls_config_h1()) }) .await; - let response = srv.sget("/").send().await.unwrap(); + let response = h1_client().get(srv.surl("/")).send().await.unwrap(); assert!(response.status().is_success()); + srv.stop().await; Ok(()) } #[actix_rt::test] async fn h2() -> io::Result<()> { - let srv = test_server(move || { + let mut srv = test_server(move || { HttpService::build() .h2(|_| ok::<_, Error>(Response::ok())) .rustls_0_23(tls_config_h2()) @@ -129,12 +147,13 @@ async fn h2() -> io::Result<()> { let response = srv.sget("/").send().await.unwrap(); assert!(response.status().is_success()); + srv.stop().await; Ok(()) } #[actix_rt::test] async fn h1_1() -> io::Result<()> { - let srv = test_server(move || { + let mut srv = test_server(move || { HttpService::build() .h1(|req: Request| { assert!(req.peer_addr().is_some()); @@ -145,14 +164,15 @@ async fn h1_1() -> io::Result<()> { }) .await; - let response = srv.sget("/").send().await.unwrap(); + let response = h1_client().get(srv.surl("/")).send().await.unwrap(); assert!(response.status().is_success()); + srv.stop().await; Ok(()) } #[actix_rt::test] async fn h2_1() -> io::Result<()> { - let srv = test_server(move || { + let mut srv = test_server(move || { HttpService::build() .finish(|req: Request| { assert!(req.peer_addr().is_some()); @@ -168,12 +188,13 @@ async fn h2_1() -> io::Result<()> { let response = srv.sget("/").send().await.unwrap(); assert!(response.status().is_success()); + srv.stop().await; Ok(()) } #[actix_rt::test] async fn h2_tcp_nodelay_override_true() -> io::Result<()> { - let srv = test_server(move || { + let mut srv = test_server(move || { HttpService::build() .tcp_nodelay(true) .on_connect_ext(|io: &TlsStream, data| { @@ -189,12 +210,13 @@ async fn h2_tcp_nodelay_override_true() -> io::Result<()> { let response = srv.sget("/").send().await.unwrap(); assert!(response.status().is_success()); + srv.stop().await; Ok(()) } #[actix_rt::test] async fn h2_tcp_nodelay_override_false() -> io::Result<()> { - let srv = test_server(move || { + let mut srv = test_server(move || { HttpService::build() .tcp_nodelay(false) .on_connect_ext(|io: &TlsStream, data| { @@ -210,6 +232,7 @@ async fn h2_tcp_nodelay_override_false() -> io::Result<()> { let response = srv.sget("/").send().await.unwrap(); assert!(response.status().is_success()); + srv.stop().await; Ok(()) } @@ -231,12 +254,13 @@ async fn h2_body1() -> io::Result<()> { let body = srv.load_body(response).await.unwrap(); assert_eq!(&body, data.as_bytes()); + srv.stop().await; Ok(()) } #[actix_rt::test] async fn h2_content_length() { - let srv = test_server(move || { + let mut srv = test_server(move || { HttpService::build() .h2(|req: Request| { let indx: usize = req.uri().path()[1..].parse().unwrap(); @@ -294,6 +318,8 @@ async fn h2_content_length() { assert_eq!(response.headers().get(&header), Some(&value)); } } + + srv.stop().await; } #[actix_rt::test] @@ -336,6 +362,7 @@ async fn h2_headers() { // read response let bytes = srv.load_body(response).await.unwrap(); assert_eq!(bytes, Bytes::from(data2)); + srv.stop().await; } const STR: &str = "Hello World Hello World Hello World Hello World Hello World \ @@ -375,6 +402,7 @@ async fn h2_body2() { // read response let bytes = srv.load_body(response).await.unwrap(); assert_eq!(bytes, Bytes::from_static(STR.as_ref())); + srv.stop().await; } #[actix_rt::test] @@ -401,6 +429,7 @@ async fn h2_head_empty() { // read response let bytes = srv.load_body(response).await.unwrap(); assert!(bytes.is_empty()); + srv.stop().await; } #[actix_rt::test] @@ -426,11 +455,12 @@ async fn h2_head_binary() { // read response let bytes = srv.load_body(response).await.unwrap(); assert!(bytes.is_empty()); + srv.stop().await; } #[actix_rt::test] async fn h2_head_binary2() { - let srv = test_server(move || { + let mut srv = test_server(move || { HttpService::build() .h2(|_| ok::<_, Infallible>(Response::ok().set_body(STR))) .rustls_0_23(tls_config_h2()) @@ -447,6 +477,8 @@ async fn h2_head_binary2() { .unwrap(); assert_eq!(format!("{}", STR.len()), len.to_str().unwrap()); } + + srv.stop().await; } #[actix_rt::test] @@ -469,6 +501,7 @@ async fn h2_body_length() { // read response let bytes = srv.load_body(response).await.unwrap(); assert_eq!(bytes, Bytes::from_static(STR.as_ref())); + srv.stop().await; } #[actix_rt::test] @@ -496,6 +529,7 @@ async fn h2_body_chunked_explicit() { // decode assert_eq!(bytes, Bytes::from_static(STR.as_ref())); + srv.stop().await; } #[actix_rt::test] @@ -525,6 +559,7 @@ async fn h2_response_http_error_handling() { bytes, Bytes::from_static(b"error processing HTTP: failed to parse header value") ); + srv.stop().await; } #[derive(Debug, Display, Error)] @@ -552,6 +587,7 @@ async fn h2_service_error() { // read response let bytes = srv.load_body(response).await.unwrap(); assert_eq!(bytes, Bytes::from_static(b"error")); + srv.stop().await; } #[actix_rt::test] @@ -563,12 +599,13 @@ async fn h1_service_error() { }) .await; - let response = srv.sget("/").send().await.unwrap(); + let response = h1_client().get(srv.surl("/")).send().await.unwrap(); assert_eq!(response.status(), http::StatusCode::BAD_REQUEST); // read response let bytes = srv.load_body(response).await.unwrap(); assert_eq!(bytes, Bytes::from_static(b"error")); + srv.stop().await; } const H2_ALPN_PROTOCOL: &[u8] = b"h2"; @@ -577,7 +614,7 @@ const CUSTOM_ALPN_PROTOCOL: &[u8] = b"custom"; #[actix_rt::test] async fn alpn_h1() -> io::Result<()> { - let srv = test_server(move || { + let mut srv = test_server(move || { let mut config = tls_config_h1(); config.alpn_protocols.push(CUSTOM_ALPN_PROTOCOL.to_vec()); HttpService::build() @@ -591,15 +628,16 @@ async fn alpn_h1() -> io::Result<()> { Some(CUSTOM_ALPN_PROTOCOL.to_vec()) ); - let response = srv.sget("/").send().await.unwrap(); + let response = h1_client().get(srv.surl("/")).send().await.unwrap(); assert!(response.status().is_success()); + srv.stop().await; Ok(()) } #[actix_rt::test] async fn alpn_h2() -> io::Result<()> { - let srv = test_server(move || { + let mut srv = test_server(move || { let mut config = tls_config_h2(); config.alpn_protocols.push(CUSTOM_ALPN_PROTOCOL.to_vec()); HttpService::build() @@ -620,12 +658,13 @@ async fn alpn_h2() -> io::Result<()> { let response = srv.sget("/").send().await.unwrap(); assert!(response.status().is_success()); + srv.stop().await; Ok(()) } #[actix_rt::test] async fn alpn_h2_1() -> io::Result<()> { - let srv = test_server(move || { + let mut srv = test_server(move || { let mut config = tls_config(); config.alpn_protocols.push(CUSTOM_ALPN_PROTOCOL.to_vec()); HttpService::build() @@ -650,5 +689,6 @@ async fn alpn_h2_1() -> io::Result<()> { let response = srv.sget("/").send().await.unwrap(); assert!(response.status().is_success()); + srv.stop().await; Ok(()) } diff --git a/actix-http/tests/test_server.rs b/actix-http/tests/test_server.rs index 434652290..4b4e435bd 100644 --- a/actix-http/tests/test_server.rs +++ b/actix-http/tests/test_server.rs @@ -962,7 +962,13 @@ async fn h2_flow_control_window_sizes() { let mut srv = test_server(|| { HttpService::build() .keep_alive(KeepAlive::Disabled) - .finish(|_req: Request| ok::<_, Infallible>(Response::ok())) + .finish(|mut req: Request| async move { + while let Some(item) = req.take_payload().next().await { + item?; + } + + Ok::<_, Error>(Response::ok()) + }) .tcp_auto_h2c() }) .await; @@ -992,8 +998,8 @@ async fn h2_flow_control_window_sizes() { loop { let cap = std::future::poll_fn(|cx| send.poll_capacity(cx)) .await - .unwrap() - .unwrap(); + .expect("request stream closed before flow control capacity became available") + .expect("failed polling flow control capacity"); if cap >= 1024 * 1024 { break cap; @@ -1001,7 +1007,7 @@ async fn h2_flow_control_window_sizes() { } }) .await - .unwrap(); + .expect("timed out waiting for flow control capacity"); assert!( cap >= 1024 * 1024, diff --git a/actix-router/CHANGES.md b/actix-router/CHANGES.md index 4c852ba75..30101e3ee 100644 --- a/actix-router/CHANGES.md +++ b/actix-router/CHANGES.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.5.4 + - Minimum supported Rust version (MSRV) is now 1.88. - Support `deserialize_any` in `PathDeserializer` (enables derived `#[serde(untagged)]` enums in path segments). [#2881] - Fix stale path segment indices after path rewrites, preventing out-of-bounds access during extraction. [#3562] diff --git a/actix-router/Cargo.toml b/actix-router/Cargo.toml index ba801188a..38be79944 100644 --- a/actix-router/Cargo.toml +++ b/actix-router/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "actix-router" -version = "0.5.3" +version = "0.5.4" authors = [ "Nikolay Kim ", "Ali MJ Al-Nasrawy ", diff --git a/actix-router/src/resource_path.rs b/actix-router/src/resource_path.rs index 610dc344d..8823108ae 100644 --- a/actix-router/src/resource_path.rs +++ b/actix-router/src/resource_path.rs @@ -1,11 +1,16 @@ use crate::Path; -// TODO: this trait is necessary, document it -// see impl Resource for ServiceRequest +/// Abstraction over types that can provide a mutable [`Path`] for routing. +/// +/// This trait is used by the router to extract the request path in a uniform way across different +/// request types (e.g., Actix Web's `ServiceRequest`). Implementors return a mutable [`Path`] +/// wrapper so routing can read and potentially normalize/parse the path without requiring the +/// original request type. pub trait Resource { /// Type of resource's path returned in `resource_path`. type Path: ResourcePath; + /// Returns a mutable reference to the path wrapper used by the router. fn resource_path(&mut self) -> &mut Path; } diff --git a/actix-web/CHANGES.md b/actix-web/CHANGES.md index 4d33d6753..ba53e912a 100644 --- a/actix-web/CHANGES.md +++ b/actix-web/CHANGES.md @@ -2,6 +2,14 @@ ## Unreleased +- Panic when calling `Route::to()` or `Route::service()` after `Route::wrap()` to prevent silently dropping route middleware. [#3944] +- Fix `HttpRequest::{match_pattern,match_name}` reporting path-only matches when route guards disambiguate overlapping resources. [#3346] + +[#3944]: https://github.com/actix/actix-web/pull/3944 +[#3346]: https://github.com/actix/actix-web/issues/3346 + +## 4.13.0 + - Minimum supported Rust version (MSRV) is now 1.88. - Improve HTTP/2 upload throughput by increasing default flow control window sizes. [#3638] - Add `HttpServer::{h2_initial_window_size, h2_initial_connection_window_size}` methods for tuning. [#3638] diff --git a/actix-web/Cargo.toml b/actix-web/Cargo.toml index 4328af0bc..13b5615f7 100644 --- a/actix-web/Cargo.toml +++ b/actix-web/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "actix-web" -version = "4.12.1" +version = "4.13.0" description = "Actix Web is a powerful, pragmatic, and extremely fast web framework for Rust" authors = ["Nikolay Kim ", "Rob Ede "] keywords = ["actix", "http", "web", "framework", "async"] @@ -137,8 +137,8 @@ actix-service = "2" actix-tls = { version = "3.4", default-features = false, optional = true } actix-utils = "3" -actix-http = "3.11.2" -actix-router = { version = "0.5.3", default-features = false, features = ["http"] } +actix-http = "3.12.0" +actix-router = { version = "0.5.4", default-features = false, features = ["http"] } actix-web-codegen = { version = "4.3", optional = true, default-features = false } bytes = "1" diff --git a/actix-web/README.md b/actix-web/README.md index f25d78531..eab4587b0 100644 --- a/actix-web/README.md +++ b/actix-web/README.md @@ -8,10 +8,10 @@ [![crates.io](https://img.shields.io/crates/v/actix-web?label=latest)](https://crates.io/crates/actix-web) -[![Documentation](https://docs.rs/actix-web/badge.svg?version=4.12.1)](https://docs.rs/actix-web/4.12.1) +[![Documentation](https://docs.rs/actix-web/badge.svg?version=4.13.0)](https://docs.rs/actix-web/4.13.0) ![MSRV](https://img.shields.io/badge/rustc-1.88+-ab6000.svg) ![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/actix-web.svg) -[![Dependency Status](https://deps.rs/crate/actix-web/4.12.1/status.svg)](https://deps.rs/crate/actix-web/4.12.1) +[![Dependency Status](https://deps.rs/crate/actix-web/4.13.0/status.svg)](https://deps.rs/crate/actix-web/4.13.0)
[![CI](https://github.com/actix/actix-web/actions/workflows/ci.yml/badge.svg)](https://github.com/actix/actix-web/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/actix/actix-web/graph/badge.svg?token=dSwOnp9QCv)](https://codecov.io/gh/actix/actix-web) @@ -45,6 +45,10 @@ To enable faster release iterations, we mark some features as experimental. These features are prefixed with `experimental` and a breaking change may happen at any release. Please use them in a production environment at your own risk. +- `experimental-introspection`: exposes route and method reporting helpers for local diagnostics + and tooling. See [`examples/introspection.rs`](examples/introspection.rs) and + [`examples/introspection_multi_servers.rs`](examples/introspection_multi_servers.rs). + ## Documentation - [Website & User Guide](https://actix.rs) diff --git a/actix-web/src/app_service.rs b/actix-web/src/app_service.rs index 7622bd68a..fadcf825b 100644 --- a/actix-web/src/app_service.rs +++ b/actix-web/src/app_service.rs @@ -228,6 +228,8 @@ where let inner = Rc::get_mut(&mut req.inner).unwrap(); inner.path.get_mut().update(&head.uri); inner.path.reset(); + inner.resource_path.clear(); + inner.resource_path_matched = false; inner.head = head; inner.conn_data = conn_data; inner.extensions = extensions; @@ -332,7 +334,15 @@ impl Service for AppRouting { guards.iter().all(|guard| guard.check(&guard_ctx)) }); - if let Some((srv, _info)) = res { + if let Some((srv, info)) = res { + req.push_resource_id(info.0); + + let matched = req + .resource_map() + .is_resource_path_match(req.resource_id_path()); + + req.mark_resource_path(matched); + srv.call(req) } else { self.default.call(req) diff --git a/actix-web/src/lib.rs b/actix-web/src/lib.rs index cca0a7616..53ee93c83 100644 --- a/actix-web/src/lib.rs +++ b/actix-web/src/lib.rs @@ -74,6 +74,9 @@ //! To enable faster release iterations, we mark some features as experimental. //! These features are prefixed with `experimental` and a breaking change may happen at any release. //! Please use them in a production environment at your own risk. +//! +//! - `experimental-introspection` - route and method reporting utilities for local diagnostics +//! and tooling. See `examples/introspection.rs` and `examples/introspection_multi_servers.rs`. #![doc(html_logo_url = "https://actix.rs/img/logo.png")] #![doc(html_favicon_url = "https://actix.rs/favicon.ico")] diff --git a/actix-web/src/request.rs b/actix-web/src/request.rs index 90a437928..852a5a80f 100644 --- a/actix-web/src/request.rs +++ b/actix-web/src/request.rs @@ -42,6 +42,8 @@ pub struct HttpRequest { pub(crate) struct HttpRequestInner { pub(crate) head: Message, pub(crate) path: Path, + pub(crate) resource_path: SmallVec<[u16; 4]>, + pub(crate) resource_path_matched: bool, pub(crate) app_data: SmallVec<[Rc; 4]>, pub(crate) conn_data: Option>, pub(crate) extensions: Rc>, @@ -65,6 +67,8 @@ impl HttpRequest { inner: Rc::new(HttpRequestInner { head, path, + resource_path: SmallVec::new(), + resource_path_matched: false, app_state, app_data: data, conn_data, @@ -180,6 +184,26 @@ impl HttpRequest { &mut Rc::get_mut(&mut self.inner).unwrap().path } + #[inline] + pub(crate) fn push_resource_id(&mut self, id: u16) { + Rc::get_mut(&mut self.inner).unwrap().resource_path.push(id); + } + + #[inline] + pub(crate) fn mark_resource_path(&mut self, is_matched: bool) { + Rc::get_mut(&mut self.inner).unwrap().resource_path_matched = is_matched; + } + + #[inline] + pub(crate) fn resource_path(&self) -> &[u16] { + &self.inner.resource_path + } + + #[inline] + pub(crate) fn is_resource_path_matched(&self) -> bool { + self.inner.resource_path_matched + } + /// The resource definition pattern that matched the path. Useful for logging and metrics. /// /// For example, when a resource with pattern `/user/{id}/profile` is defined and a call is made @@ -188,6 +212,15 @@ impl HttpRequest { /// Returns a None when no resource is fully matched, including default services. #[inline] pub fn match_pattern(&self) -> Option { + if self.is_resource_path_matched() { + if let Some(pattern) = self + .resource_map() + .match_pattern_by_resource_path(self.resource_path()) + { + return Some(pattern); + } + } + self.resource_map().match_pattern(self.path()) } @@ -196,6 +229,15 @@ impl HttpRequest { /// Returns a None when no resource is fully matched, including default services. #[inline] pub fn match_name(&self) -> Option<&str> { + if self.is_resource_path_matched() { + if let Some(name) = self + .resource_map() + .match_name_by_resource_path(self.resource_path()) + { + return Some(name); + } + } + self.resource_map().match_name(self.path()) } @@ -633,6 +675,7 @@ mod tests { use super::*; use crate::{ dev::{ResourceDef, Service}, + guard, http::{header, StatusCode}, test::{self, call_service, init_service, read_body, TestRequest}, web, App, HttpResponse, @@ -1019,6 +1062,44 @@ mod tests { assert_eq!(res.status(), StatusCode::OK); } + #[actix_rt::test] + async fn extract_path_pattern_with_guards() { + let srv = init_service( + App::new().service( + web::scope("/widgets") + .service( + web::resource("/{id}") + .name("get_widget") + .guard(guard::Get()) + .to(|req: HttpRequest| { + assert_eq!(req.match_pattern(), Some("/widgets/{id}".to_owned())); + assert_eq!(req.match_name(), Some("get_widget")); + HttpResponse::Ok().finish() + }), + ) + .service( + web::resource("/action") + .name("widget_action") + .guard(guard::Post()) + .to(|req: HttpRequest| { + assert_eq!(req.match_pattern(), Some("/widgets/action".to_owned())); + assert_eq!(req.match_name(), Some("widget_action")); + HttpResponse::Ok().finish() + }), + ), + ), + ) + .await; + + let req = TestRequest::get().uri("/widgets/42").to_request(); + let res = call_service(&srv, req).await; + assert_eq!(res.status(), StatusCode::OK); + + let req = TestRequest::post().uri("/widgets/action").to_request(); + let res = call_service(&srv, req).await; + assert_eq!(res.status(), StatusCode::OK); + } + #[actix_rt::test] async fn extract_path_pattern_complex() { let srv = init_service( diff --git a/actix-web/src/rmap.rs b/actix-web/src/rmap.rs index ee86d271b..d3e7c3917 100644 --- a/actix-web/src/rmap.rs +++ b/actix-web/src/rmap.rs @@ -240,10 +240,40 @@ impl ResourceMap { ) } + pub(crate) fn is_resource_path_match(&self, resource_path: &[u16]) -> bool { + self.find_node_by_resource_path(resource_path) + .is_some_and(|node| node.nodes.is_none()) + } + + pub(crate) fn match_name_by_resource_path(&self, resource_path: &[u16]) -> Option<&str> { + self.find_node_by_resource_path(resource_path)? + .pattern + .name() + } + + pub(crate) fn match_pattern_by_resource_path(&self, resource_path: &[u16]) -> Option { + self.find_node_by_resource_path(resource_path)? + .root_rmap_fn(String::with_capacity(AVG_PATH_LEN), |mut acc, node| { + let pattern = node.pattern.pattern()?; + acc.push_str(pattern); + Some(acc) + }) + } + fn find_matching_node(&self, path: &str) -> Option<&ResourceMap> { self._find_matching_node(path).flatten() } + fn find_node_by_resource_path(&self, resource_path: &[u16]) -> Option<&ResourceMap> { + let mut node = self; + + for id in resource_path { + node = node.nodes.as_ref()?.get(*id as usize)?; + } + + Some(node) + } + /// Returns `None` if root pattern doesn't match; /// `Some(None)` if root pattern matches but there is no matching child pattern. /// Don't search sideways when `Some(none)` is returned. diff --git a/actix-web/src/route.rs b/actix-web/src/route.rs index 65d7dcef0..feb94338e 100644 --- a/actix-web/src/route.rs +++ b/actix-web/src/route.rs @@ -23,6 +23,7 @@ use crate::{ pub struct Route { service: BoxedHttpServiceFactory, guards: Rc>>, + wrapped: bool, } impl Route { @@ -34,6 +35,7 @@ impl Route { Ok(req.into_response(HttpResponse::NotFound())) })), guards: Rc::new(Vec::new()), + wrapped: false, } } @@ -42,6 +44,17 @@ impl Route { /// `mw` is a middleware component (type), that can modify the requests and responses handled by /// this `Route`. /// + /// This middleware wraps the currently configured route service. Call this method after + /// [`Route::to`] or [`Route::service`] so the middleware is applied to the final handler. + /// + /// # Examples + /// ``` + /// # use actix_web::{web, HttpResponse, middleware}; + /// web::get() + /// .to(|| async { HttpResponse::Ok() }) + /// .wrap(middleware::Logger::default()); + /// ``` + /// /// See [`App::wrap`](crate::App::wrap) for more details. #[doc(alias = "middleware")] #[doc(alias = "use")] // nodejs terminology @@ -59,12 +72,24 @@ impl Route { Route { service: boxed::factory(apply(Compat::new(mw), self.service)), guards: self.guards, + wrapped: true, } } pub(crate) fn take_guards(&mut self) -> Vec> { mem::take(Rc::get_mut(&mut self.guards).unwrap()) } + + #[cold] + #[inline(never)] + #[track_caller] + fn panic_after_wrap(replaced: &str, example: &str) -> ! { + panic!( + "Route middleware was already registered with `.wrap()`. \ + Calling `.{replaced}()` now would replace the wrapped service and silently drop middleware. \ + Call `.{replaced}()` before `.wrap()` (for example: `{example}`)." + ); + } } impl ServiceFactory for Route { @@ -212,12 +237,21 @@ impl Route { /// .route(web::get().to(index)) /// ); /// ``` + /// + /// # Panics + /// Panics if called after [`Route::wrap`], since this would replace the wrapped service and + /// silently discard middleware. + #[track_caller] pub fn to(mut self, handler: F) -> Self where F: Handler, Args: FromRequest + 'static, F::Output: Responder + 'static, { + if self.wrapped { + Self::panic_after_wrap("to", "web::get().to(handler).wrap(mw)"); + } + self.service = handler_service(handler); self } @@ -254,6 +288,11 @@ impl Route { /// web::get().service(fn_factory(|| async { Ok(HelloWorld) })), /// ); /// ``` + /// + /// # Panics + /// Panics if called after [`Route::wrap`], since this would replace the wrapped service and + /// silently discard middleware. + #[track_caller] pub fn service(mut self, service_factory: S) -> Self where S: ServiceFactory< @@ -265,6 +304,10 @@ impl Route { > + 'static, E: Into + 'static, { + if self.wrapped { + Self::panic_after_wrap("service", "web::get().service(factory).wrap(mw)"); + } + self.service = boxed::factory(service_factory.map_err(Into::into)); self } @@ -459,4 +502,25 @@ mod tests { Bytes::from_static(b"Goodbye, and thanks for all the fish!") ); } + + #[test] + #[should_panic(expected = "Route middleware was already registered with `.wrap()`")] + fn wrap_before_to_panics() { + web::get() + .wrap(DefaultHeaders::new().add(("x-test", "x-value"))) + .to(HttpResponse::Ok); + } + + #[test] + #[should_panic(expected = "Route middleware was already registered with `.wrap()`")] + fn wrap_before_service_panics() { + web::get() + .wrap(DefaultHeaders::new().add(("x-test", "x-value"))) + .service(fn_factory(|| async { + Ok::<_, ()>(fn_service(|req: ServiceRequest| async { + let (req, _) = req.into_parts(); + Ok::<_, Infallible>(ServiceResponse::new(req, HttpResponse::Ok().finish())) + })) + })); + } } diff --git a/actix-web/src/scope.rs b/actix-web/src/scope.rs index c00c51bed..560a66b8e 100644 --- a/actix-web/src/scope.rs +++ b/actix-web/src/scope.rs @@ -533,7 +533,15 @@ impl Service for ScopeService { guards.iter().all(|guard| guard.check(&guard_ctx)) }); - if let Some((srv, _info)) = res { + if let Some((srv, info)) = res { + req.push_resource_id(info.0); + + let matched = req + .resource_map() + .is_resource_path_match(req.resource_id_path()); + + req.mark_resource_path(matched); + srv.call(req) } else { self.default.call(req) diff --git a/actix-web/src/service.rs b/actix-web/src/service.rs index 6c7f6f5c8..1e819fcbc 100644 --- a/actix-web/src/service.rs +++ b/actix-web/src/service.rs @@ -321,6 +321,21 @@ impl ServiceRequest { .push(extensions); } + #[inline] + pub(crate) fn push_resource_id(&mut self, id: u16) { + self.req.push_resource_id(id); + } + + #[inline] + pub(crate) fn mark_resource_path(&mut self, is_matched: bool) { + self.req.mark_resource_path(is_matched); + } + + #[inline] + pub(crate) fn resource_id_path(&self) -> &[u16] { + self.req.resource_path() + } + /// Creates a context object for use with a routing [guard](crate::guard). #[inline] pub fn guard_ctx(&self) -> GuardContext<'_> { diff --git a/awc/CHANGES.md b/awc/CHANGES.md index 35a3dde2f..38a9f8b09 100644 --- a/awc/CHANGES.md +++ b/awc/CHANGES.md @@ -2,6 +2,12 @@ ## Unreleased +- Add camel-case header controls to `WebsocketsRequest` via `camel_case_headers()` and `set_camel_case_headers()`. [#3953] + +[#3953]: https://github.com/actix/actix-web/pull/3953 + +## 3.8.2 + - Minimum supported Rust version (MSRV) is now 1.88. - Fix empty streaming request bodies being sent with chunked transfer encoding. diff --git a/awc/Cargo.toml b/awc/Cargo.toml index 5386b7994..b5b9396b0 100644 --- a/awc/Cargo.toml +++ b/awc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "awc" -version = "3.8.1" +version = "3.8.2" authors = ["Nikolay Kim "] description = "Async HTTP and WebSocket client library" keywords = ["actix", "http", "framework", "async", "web"] @@ -98,7 +98,7 @@ dangerous-h2c = [] [dependencies] actix-codec = "0.5" -actix-http = { version = "3.10", features = ["http2", "ws"] } +actix-http = { version = "3.12.0", features = ["http2", "ws"] } actix-rt = { version = "2.1", default-features = false } actix-service = "2" actix-tls = { version = "3.4", features = ["connect", "uri"] } @@ -134,13 +134,13 @@ tls-rustls-0_23 = { package = "rustls", version = "0.23", optional = true, defau hickory-resolver = { version = "0.25", optional = true, features = ["system-config", "tokio"] } [dev-dependencies] -actix-http = { version = "3.7", features = ["openssl"] } +actix-http = { version = "3.12", features = ["openssl"] } actix-http-test = { version = "3", features = ["openssl"] } actix-server = "2" actix-test = { version = "0.1", features = ["openssl", "rustls-0_23"] } actix-tls = { version = "3.4", features = ["openssl", "rustls-0_23"] } actix-utils = "3" -actix-web = { version = "4", features = ["openssl"] } +actix-web = { version = "4.13", features = ["openssl"] } brotli = "8" const-str = "0.5" # TODO(MSRV 1.77): update to 0.6 diff --git a/awc/README.md b/awc/README.md index 6e91eab3e..4c74931e6 100644 --- a/awc/README.md +++ b/awc/README.md @@ -5,9 +5,9 @@ [![crates.io](https://img.shields.io/crates/v/awc?label=latest)](https://crates.io/crates/awc) -[![Documentation](https://docs.rs/awc/badge.svg?version=3.8.1)](https://docs.rs/awc/3.8.1) +[![Documentation](https://docs.rs/awc/badge.svg?version=3.8.2)](https://docs.rs/awc/3.8.2) ![MIT or Apache 2.0 licensed](https://img.shields.io/crates/l/awc) -[![Dependency Status](https://deps.rs/crate/awc/3.8.1/status.svg)](https://deps.rs/crate/awc/3.8.1) +[![Dependency Status](https://deps.rs/crate/awc/3.8.2/status.svg)](https://deps.rs/crate/awc/3.8.2) [![Chat on Discord](https://img.shields.io/discord/771444961383153695?label=chat&logo=discord)](https://discord.gg/NWpN5mmg3x) diff --git a/awc/src/ws.rs b/awc/src/ws.rs index c33531d41..de8ed2d14 100644 --- a/awc/src/ws.rs +++ b/awc/src/ws.rs @@ -245,6 +245,21 @@ impl WebsocketsRequest { self.header(AUTHORIZATION, format!("Bearer {}", token)) } + /// Returns whether headers should be sent in Camel-Case. + /// + /// Default is `false`. + #[inline] + pub fn camel_case_headers(&self) -> bool { + self.head.camel_case_headers() + } + + /// Sets whether to send headers formatted as Camel-Case. + #[inline] + pub fn set_camel_case_headers(mut self, val: bool) -> Self { + self.head.set_camel_case_headers(val); + self + } + /// Complete request construction and connect to a WebSocket server. pub async fn connect( mut self, @@ -529,6 +544,12 @@ mod tests { let _ = req.connect(); } + #[actix_rt::test] + async fn camel_case_headers() { + let req = Client::new().ws("/").set_camel_case_headers(true); + assert!(req.camel_case_headers()); + } + #[actix_rt::test] async fn basics() { let req = Client::new()