From 4bea9d89ad4834a043c0c633229737bc711139cb Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 13 Jun 2026 14:12:55 +0000 Subject: [PATCH 01/40] feat(router): Shut down gracefully on interrupt under Tokio --- Cargo.toml | 1 + src/router.rs | 151 ++++++++++++++++++++++++++++++++++---------------- 2 files changed, 105 insertions(+), 47 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6d08baf..e4d1af4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ tokio = { version = "1.52.3", default-features = false, features = [ "net", "io-util", "macros", + "signal", ], optional = true } async-trait = "0.1.68" async-std = { version = "1.12.0", features = ["attributes"], optional = true } diff --git a/src/router.rs b/src/router.rs index ad9526b..4d0b73a 100644 --- a/src/router.rs +++ b/src/router.rs @@ -63,9 +63,14 @@ macro_rules! or_error { } #[cfg(feature = "tokio")] -type Stream = tokio_openssl::SslStream; +type TcpStream = tokio::net::TcpStream; #[cfg(feature = "async-std")] -type Stream = async_std_openssl::SslStream; +type TcpStream = async_std::net::TcpStream; + +#[cfg(feature = "tokio")] +type Stream = tokio_openssl::SslStream; +#[cfg(feature = "async-std")] +type Stream = async_std_openssl::SslStream; /// A router which takes care of all tasks a Windmark server should handle: /// response generation, panics, logging, and more. @@ -158,9 +163,10 @@ impl RequestHandler { url.set_path("/"); } - let route_path = resolve_lookup_path(&self.options, url.path(), |candidate| { - self.routes.at(candidate).is_ok() - }); + let route_path = + resolve_lookup_path(&self.options, url.path(), |candidate| { + self.routes.at(candidate).is_ok() + }); let route = self.routes.at(&route_path); let peer_certificate = stream.ssl().peer_certificate(); @@ -284,6 +290,50 @@ impl RequestHandler { } } +/// Spawn a task to complete the TLS handshake on an accepted connection and +/// dispatch it through `handler`. +fn spawn_connection( + handler: Arc, + acceptor: Arc, + stream: TcpStream, +) { + #[cfg(feature = "tokio")] + let spawner = tokio::spawn; + #[cfg(feature = "async-std")] + let spawner = async_std::task::spawn; + + spawner(async move { + let ssl = match ssl::Ssl::new(acceptor.context()) { + Ok(ssl) => ssl, + Err(e) => { + error!("ssl context error: {e:?}"); + + return; + } + }; + + #[cfg(feature = "tokio")] + let quick_stream = tokio_openssl::SslStream::new(ssl, stream); + #[cfg(feature = "async-std")] + let quick_stream = async_std_openssl::SslStream::new(ssl, stream); + + match quick_stream { + Ok(mut stream) => { + if let Err(e) = std::pin::Pin::new(&mut stream).accept().await { + warn!("stream accept error: {e:?}"); + + return; + } + + if let Err(e) = handler.handle(&mut stream).await { + error!("handle error: {e}"); + } + } + Err(e) => error!("ssl stream error: {e:?}"), + } + }); +} + /// Resolve which path an incoming request should be matched against, applying /// the configured path-fixing `options`. /// @@ -514,6 +564,11 @@ impl Router { /// Run the `Router` and wait for requests /// + /// Under the default Tokio runtime, the server runs until it receives an + /// interrupt (Ctrl+C / SIGINT), at which point it stops accepting new + /// connections and returns. The `async-std` runtime runs until the process + /// is terminated. + /// /// # Examples /// /// ```rust @@ -581,47 +636,37 @@ impl Router { options: self.options.clone(), }); + let acceptor = self.ssl_acceptor.clone(); + + // Under Tokio, accept connections until interrupted (Ctrl+C / SIGINT) so + // the server can shut down gracefully; `async-std` has no built-in signal + // handling, so it keeps the original unconditional accept loop. + #[cfg(feature = "tokio")] + { + loop { + tokio::select! { + connection = listener.accept() => match connection { + Ok((stream, _)) => + spawn_connection(handler.clone(), acceptor.clone(), stream), + Err(e) => error!("tcp stream error: {e:?}"), + }, + _ = tokio::signal::ctrl_c() => { + #[cfg(feature = "logger")] + info!("windmark received an interrupt; shutting down"); + + break; + } + } + } + + Ok(()) + } + + #[cfg(feature = "async-std")] loop { match listener.accept().await { - Ok((stream, _)) => { - let handler = Arc::clone(&handler); - let acceptor = self.ssl_acceptor.clone(); - #[cfg(feature = "tokio")] - let spawner = tokio::spawn; - #[cfg(feature = "async-std")] - let spawner = async_std::task::spawn; - - spawner(async move { - let ssl = match ssl::Ssl::new(acceptor.context()) { - Ok(ssl) => ssl, - Err(e) => { - error!("ssl context error: {e:?}"); - - return; - } - }; - - #[cfg(feature = "tokio")] - let quick_stream = tokio_openssl::SslStream::new(ssl, stream); - #[cfg(feature = "async-std")] - let quick_stream = async_std_openssl::SslStream::new(ssl, stream); - - match quick_stream { - Ok(mut stream) => { - if let Err(e) = std::pin::Pin::new(&mut stream).accept().await { - warn!("stream accept error: {e:?}"); - - return; - } - - if let Err(e) = handler.handle(&mut stream).await { - error!("handle error: {e}"); - } - } - Err(e) => error!("ssl stream error: {e:?}"), - } - }); - } + Ok((stream, _)) => + spawn_connection(handler.clone(), acceptor.clone(), stream), Err(e) => error!("tcp stream error: {e:?}"), } } @@ -1192,7 +1237,11 @@ mod tests { #[test] fn case_insensitive_lookup_lowercases_the_path() { assert_eq!( - resolve(&[RouterOption::AllowCaseInsensitiveLookup], "/FoO", &["/foo"]), + resolve( + &[RouterOption::AllowCaseInsensitiveLookup], + "/FoO", + &["/foo"] + ), "/foo", ); } @@ -1200,7 +1249,11 @@ mod tests { #[test] fn extra_trailing_slash_is_removed_when_unslashed_route_exists() { assert_eq!( - resolve(&[RouterOption::RemoveExtraTrailingSlash], "/foo/", &["/foo"]), + resolve( + &[RouterOption::RemoveExtraTrailingSlash], + "/foo/", + &["/foo"] + ), "/foo", ); } @@ -1224,7 +1277,11 @@ mod tests { #[test] fn trailing_slash_fix_is_skipped_when_the_target_route_is_absent() { assert_eq!( - resolve(&[RouterOption::RemoveExtraTrailingSlash], "/foo/", &["/bar"]), + resolve( + &[RouterOption::RemoveExtraTrailingSlash], + "/foo/", + &["/bar"] + ), "/foo/", ); } From 44287cb540d722449a29670bef0cee7725131369 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 13 Jun 2026 14:16:18 +0000 Subject: [PATCH 02/40] ci: Run fmt, clippy, and tests on both runtimes via just --- .github/workflows/check.yaml | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index ce4442f..eebe20a 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -3,11 +3,7 @@ name: Check ✅ on: workflow_dispatch: push: - paths: - - "**" pull_request: - paths: - - "**" env: CARGO_TERM_COLOR: always @@ -17,26 +13,23 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout 🛒 - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Toolchain 🧰 - uses: actions-rs/toolchain@v1 + uses: dtolnay/rust-toolchain@nightly with: - profile: minimal - toolchain: stable components: rustfmt, clippy - override: true - - name: Check ✅ - uses: actions-rs/cargo@v1 - continue-on-error: false - with: - command: check - args: --verbose + - name: Just 📋 + uses: extractions/setup-just@v2 + + - name: Format, check, and lint ✅ + run: just checkfc + + - name: Verify formatting 🎨 + run: git diff --exit-code - name: Test 🧪 - uses: actions-rs/cargo@v1 - continue-on-error: false - with: - command: test - args: --verbose --features auto-deduce-mime,response-macros + run: | + just test tokio + just test async-std From c39fe8413356b5ca1304261b462e1a7a00d27771 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sun, 14 Jun 2026 08:02:06 +0000 Subject: [PATCH 03/40] test(router): Extract and cover status-line formatting --- src/router.rs | 157 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 113 insertions(+), 44 deletions(-) diff --git a/src/router.rs b/src/router.rs index 4d0b73a..10c2d9f 100644 --- a/src/router.rs +++ b/src/router.rs @@ -232,48 +232,8 @@ impl RequestHandler { self.post_route_callback.call(&hook_context, &mut content); - let status_code = if content.status == 21 || content.status == 22 { - 20 - } else { - content.status - }; - let status_line = match content.status { - 20 => { - let mime = content.mime.as_deref().unwrap_or("text/gemini"); - let charset = content - .character_set - .as_deref() - .unwrap_or(&self.character_set); - let lang = content.languages.as_ref().map_or_else( - || std::borrow::Cow::Borrowed(self.languages_joined.as_str()), - |l| std::borrow::Cow::Owned(l.join(",")), - ); - - format!("{status_code} {mime}; charset={charset}; lang={lang}") - } - 21 => { - format!( - "{} {}", - status_code, - content.mime.as_deref().unwrap_or_default() - ) - } - #[cfg(feature = "auto-deduce-mime")] - 22 => { - format!( - "{} {}", - status_code, - content.mime.as_deref().unwrap_or_default() - ) - } - _ => { - format!( - "{} {}", - status_code, - content.content.lines().next().unwrap_or_default() - ) - } - }; + let status_line = + status_line(&content, &self.character_set, &self.languages_joined); let body = content.serialize_body(&header, &footer); let mut response = Vec::with_capacity(status_line.len() + body.len() + 2); @@ -334,6 +294,40 @@ fn spawn_connection( }); } +/// Build the Gemini response status line for `content`, falling back to the +/// router's `default_character_set` and `default_languages` for `20` responses. +/// +/// Statuses `21`/`22` (binary success) are sent to clients as a plain `20` with +/// the response's MIME as the meta; every other status uses the first line of +/// its `content` as the meta. +fn status_line( + content: &Response, + default_character_set: &str, + default_languages: &str, +) -> String { + match content.status { + 20 => { + let mime = content.mime.as_deref().unwrap_or("text/gemini"); + let character_set = content + .character_set + .as_deref() + .unwrap_or(default_character_set); + let languages = content.languages.as_ref().map_or_else( + || std::borrow::Cow::Borrowed(default_languages), + |languages| std::borrow::Cow::Owned(languages.join(",")), + ); + + format!("20 {mime}; charset={character_set}; lang={languages}") + } + 21 | 22 => format!("20 {}", content.mime.as_deref().unwrap_or_default()), + status => + format!( + "{status} {}", + content.content.lines().next().unwrap_or_default() + ), + } +} + /// Resolve which path an incoming request should be matched against, applying /// the configured path-fixing `options`. /// @@ -1197,8 +1191,8 @@ mod tests { use matchit::Router as MatchRouter; - use super::resolve_lookup_path; - use crate::router_option::RouterOption; + use super::{resolve_lookup_path, status_line}; + use crate::{response::Response, router_option::RouterOption}; /// Resolve `request_path` against a router holding `routes`, under `options`. fn resolve( @@ -1312,4 +1306,79 @@ mod tests { "/foo", ); } + + /// Format `response`'s status line with the router's default charset and + /// language (matching `Router::default`). + fn line(response: &Response) -> String { + status_line(response, "utf-8", "en") + } + + #[test] + fn success_status_line_falls_back_to_router_defaults() { + assert_eq!( + line(&Response::success("hi")), + "20 text/gemini; charset=utf-8; lang=en", + ); + } + + #[test] + fn success_status_line_uses_the_response_mime() { + let mut response = Response::success("hi"); + + response.with_mime("text/plain"); + + assert_eq!(line(&response), "20 text/plain; charset=utf-8; lang=en"); + } + + #[test] + fn success_status_line_uses_the_response_character_set() { + let mut response = Response::success("hi"); + + response.with_character_set("iso-8859-1"); + + assert_eq!( + line(&response), + "20 text/gemini; charset=iso-8859-1; lang=en" + ); + } + + #[test] + fn success_status_line_joins_the_response_languages() { + let mut response = Response::success("hi"); + + response.with_languages(["en", "fr"]); + + assert_eq!(line(&response), "20 text/gemini; charset=utf-8; lang=en,fr"); + } + + #[test] + fn binary_success_is_sent_as_a_plain_success_with_its_mime() { + assert_eq!( + line(&Response::binary_success([0xFFu8], "image/png")), + "20 image/png" + ); + } + + #[test] + fn status_21_without_a_mime_emits_an_empty_meta() { + assert_eq!(line(&Response::new(21, "")), "20 "); + } + + #[test] + fn redirect_status_line_uses_its_content_as_the_meta() { + assert_eq!( + line(&Response::temporary_redirect("/elsewhere")), + "30 /elsewhere" + ); + } + + #[test] + fn failure_status_line_uses_only_the_first_line_of_content() { + assert_eq!(line(&Response::new(40, "down\nfor maintenance")), "40 down"); + } + + #[test] + fn input_status_line_uses_its_prompt_as_the_meta() { + assert_eq!(line(&Response::input("Your name?")), "10 Your name?"); + } } From 4f98615443696eb3ef4a7261779f34d1763f0582 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sun, 14 Jun 2026 08:12:19 +0000 Subject: [PATCH 04/40] refactor(router): Split unit tests into src/router/tests.rs --- src/router.rs | 197 +------------------------------------------- src/router/tests.rs | 192 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 196 deletions(-) create mode 100644 src/router/tests.rs diff --git a/src/router.rs b/src/router.rs index 10c2d9f..2207787 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1186,199 +1186,4 @@ impl Default for Router { } #[cfg(test)] -mod tests { - use std::collections::HashSet; - - use matchit::Router as MatchRouter; - - use super::{resolve_lookup_path, status_line}; - use crate::{response::Response, router_option::RouterOption}; - - /// Resolve `request_path` against a router holding `routes`, under `options`. - fn resolve( - options: &[RouterOption], - request_path: &str, - routes: &[&str], - ) -> String { - let mut router = MatchRouter::new(); - - for route in routes { - router.insert(*route, ()).unwrap(); - } - - let option_set = options.iter().copied().collect::>(); - - resolve_lookup_path(&option_set, request_path, |candidate| { - router.at(candidate).is_ok() - }) - } - - #[test] - fn exact_match_is_returned_unchanged() { - assert_eq!(resolve(&[], "/foo", &["/foo"]), "/foo"); - } - - #[test] - fn unmatched_path_without_options_is_returned_unchanged() { - assert_eq!(resolve(&[], "/missing", &["/foo"]), "/missing"); - } - - #[test] - fn dynamic_route_is_matched_without_modification() { - assert_eq!(resolve(&[], "/posts/42", &["/posts/:id"]), "/posts/42"); - } - - #[test] - fn case_insensitive_lookup_lowercases_the_path() { - assert_eq!( - resolve( - &[RouterOption::AllowCaseInsensitiveLookup], - "/FoO", - &["/foo"] - ), - "/foo", - ); - } - - #[test] - fn extra_trailing_slash_is_removed_when_unslashed_route_exists() { - assert_eq!( - resolve( - &[RouterOption::RemoveExtraTrailingSlash], - "/foo/", - &["/foo"] - ), - "/foo", - ); - } - - #[test] - fn root_path_is_left_untouched_by_trailing_slash_removal() { - assert_eq!( - resolve(&[RouterOption::RemoveExtraTrailingSlash], "/", &["/"]), - "/", - ); - } - - #[test] - fn missing_trailing_slash_is_added_when_slashed_route_exists() { - assert_eq!( - resolve(&[RouterOption::AddMissingTrailingSlash], "/foo", &["/foo/"]), - "/foo/", - ); - } - - #[test] - fn trailing_slash_fix_is_skipped_when_the_target_route_is_absent() { - assert_eq!( - resolve( - &[RouterOption::RemoveExtraTrailingSlash], - "/foo/", - &["/bar"] - ), - "/foo/", - ); - } - - #[test] - fn exact_match_takes_precedence_over_trailing_slash_fix() { - assert_eq!( - resolve( - &[RouterOption::RemoveExtraTrailingSlash], - "/foo/", - &["/foo", "/foo/"], - ), - "/foo/", - ); - } - - #[test] - fn case_insensitive_lookup_combines_with_trailing_slash_removal() { - assert_eq!( - resolve( - &[ - RouterOption::AllowCaseInsensitiveLookup, - RouterOption::RemoveExtraTrailingSlash, - ], - "/Foo/", - &["/foo"], - ), - "/foo", - ); - } - - /// Format `response`'s status line with the router's default charset and - /// language (matching `Router::default`). - fn line(response: &Response) -> String { - status_line(response, "utf-8", "en") - } - - #[test] - fn success_status_line_falls_back_to_router_defaults() { - assert_eq!( - line(&Response::success("hi")), - "20 text/gemini; charset=utf-8; lang=en", - ); - } - - #[test] - fn success_status_line_uses_the_response_mime() { - let mut response = Response::success("hi"); - - response.with_mime("text/plain"); - - assert_eq!(line(&response), "20 text/plain; charset=utf-8; lang=en"); - } - - #[test] - fn success_status_line_uses_the_response_character_set() { - let mut response = Response::success("hi"); - - response.with_character_set("iso-8859-1"); - - assert_eq!( - line(&response), - "20 text/gemini; charset=iso-8859-1; lang=en" - ); - } - - #[test] - fn success_status_line_joins_the_response_languages() { - let mut response = Response::success("hi"); - - response.with_languages(["en", "fr"]); - - assert_eq!(line(&response), "20 text/gemini; charset=utf-8; lang=en,fr"); - } - - #[test] - fn binary_success_is_sent_as_a_plain_success_with_its_mime() { - assert_eq!( - line(&Response::binary_success([0xFFu8], "image/png")), - "20 image/png" - ); - } - - #[test] - fn status_21_without_a_mime_emits_an_empty_meta() { - assert_eq!(line(&Response::new(21, "")), "20 "); - } - - #[test] - fn redirect_status_line_uses_its_content_as_the_meta() { - assert_eq!( - line(&Response::temporary_redirect("/elsewhere")), - "30 /elsewhere" - ); - } - - #[test] - fn failure_status_line_uses_only_the_first_line_of_content() { - assert_eq!(line(&Response::new(40, "down\nfor maintenance")), "40 down"); - } - - #[test] - fn input_status_line_uses_its_prompt_as_the_meta() { - assert_eq!(line(&Response::input("Your name?")), "10 Your name?"); - } -} +mod tests; diff --git a/src/router/tests.rs b/src/router/tests.rs new file mode 100644 index 0000000..77434f3 --- /dev/null +++ b/src/router/tests.rs @@ -0,0 +1,192 @@ +use std::collections::HashSet; + +use matchit::Router as MatchRouter; + +use super::{resolve_lookup_path, status_line}; +use crate::{response::Response, router_option::RouterOption}; + +/// Resolve `request_path` against a router holding `routes`, under `options`. +fn resolve( + options: &[RouterOption], + request_path: &str, + routes: &[&str], +) -> String { + let mut router = MatchRouter::new(); + + for route in routes { + router.insert(*route, ()).unwrap(); + } + + let option_set = options.iter().copied().collect::>(); + + resolve_lookup_path(&option_set, request_path, |candidate| { + router.at(candidate).is_ok() + }) +} + +#[test] +fn exact_match_is_returned_unchanged() { + assert_eq!(resolve(&[], "/foo", &["/foo"]), "/foo"); +} + +#[test] +fn unmatched_path_without_options_is_returned_unchanged() { + assert_eq!(resolve(&[], "/missing", &["/foo"]), "/missing"); +} + +#[test] +fn dynamic_route_is_matched_without_modification() { + assert_eq!(resolve(&[], "/posts/42", &["/posts/:id"]), "/posts/42"); +} + +#[test] +fn case_insensitive_lookup_lowercases_the_path() { + assert_eq!( + resolve( + &[RouterOption::AllowCaseInsensitiveLookup], + "/FoO", + &["/foo"] + ), + "/foo", + ); +} + +#[test] +fn extra_trailing_slash_is_removed_when_unslashed_route_exists() { + assert_eq!( + resolve( + &[RouterOption::RemoveExtraTrailingSlash], + "/foo/", + &["/foo"] + ), + "/foo", + ); +} + +#[test] +fn root_path_is_left_untouched_by_trailing_slash_removal() { + assert_eq!( + resolve(&[RouterOption::RemoveExtraTrailingSlash], "/", &["/"]), + "/", + ); +} + +#[test] +fn missing_trailing_slash_is_added_when_slashed_route_exists() { + assert_eq!( + resolve(&[RouterOption::AddMissingTrailingSlash], "/foo", &["/foo/"]), + "/foo/", + ); +} + +#[test] +fn trailing_slash_fix_is_skipped_when_the_target_route_is_absent() { + assert_eq!( + resolve( + &[RouterOption::RemoveExtraTrailingSlash], + "/foo/", + &["/bar"] + ), + "/foo/", + ); +} + +#[test] +fn exact_match_takes_precedence_over_trailing_slash_fix() { + assert_eq!( + resolve( + &[RouterOption::RemoveExtraTrailingSlash], + "/foo/", + &["/foo", "/foo/"], + ), + "/foo/", + ); +} + +#[test] +fn case_insensitive_lookup_combines_with_trailing_slash_removal() { + assert_eq!( + resolve( + &[ + RouterOption::AllowCaseInsensitiveLookup, + RouterOption::RemoveExtraTrailingSlash, + ], + "/Foo/", + &["/foo"], + ), + "/foo", + ); +} + +/// Format `response`'s status line with the router's default charset and +/// language (matching `Router::default`). +fn line(response: &Response) -> String { status_line(response, "utf-8", "en") } + +#[test] +fn success_status_line_falls_back_to_router_defaults() { + assert_eq!( + line(&Response::success("hi")), + "20 text/gemini; charset=utf-8; lang=en", + ); +} + +#[test] +fn success_status_line_uses_the_response_mime() { + let mut response = Response::success("hi"); + + response.with_mime("text/plain"); + + assert_eq!(line(&response), "20 text/plain; charset=utf-8; lang=en"); +} + +#[test] +fn success_status_line_uses_the_response_character_set() { + let mut response = Response::success("hi"); + + response.with_character_set("iso-8859-1"); + + assert_eq!( + line(&response), + "20 text/gemini; charset=iso-8859-1; lang=en" + ); +} + +#[test] +fn success_status_line_joins_the_response_languages() { + let mut response = Response::success("hi"); + + response.with_languages(["en", "fr"]); + + assert_eq!(line(&response), "20 text/gemini; charset=utf-8; lang=en,fr"); +} + +#[test] +fn binary_success_is_sent_as_a_plain_success_with_its_mime() { + assert_eq!( + line(&Response::binary_success([0xFFu8], "image/png")), + "20 image/png" + ); +} + +#[test] +fn status_21_without_a_mime_emits_an_empty_meta() { + assert_eq!(line(&Response::new(21, "")), "20 "); +} + +#[test] +fn redirect_status_line_uses_its_content_as_the_meta() { + assert_eq!( + line(&Response::temporary_redirect("/elsewhere")), + "30 /elsewhere" + ); +} + +#[test] +fn failure_status_line_uses_only_the_first_line_of_content() { + assert_eq!(line(&Response::new(40, "down\nfor maintenance")), "40 down"); +} + +#[test] +fn input_status_line_uses_its_prompt_as_the_meta() { + assert_eq!(line(&Response::input("Your name?")), "10 Your name?"); +} From 95c069f79057613442440a35abf3bf9b19072af4 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Fri, 11 Sep 2026 06:50:28 +0000 Subject: [PATCH 05/40] style: Keep consecutive imports in one group --- examples/error_handler.rs | 1 - .../router/parser/field_initializers.rs | 3 +- rustfmt.toml | 2 +- src/handler.rs | 3 +- src/router.rs | 40 +++++++++---------- src/router/tests.rs | 6 +-- 6 files changed, 24 insertions(+), 31 deletions(-) diff --git a/examples/error_handler.rs b/examples/error_handler.rs index 7d3ca25..74a70a2 100644 --- a/examples/error_handler.rs +++ b/examples/error_handler.rs @@ -4,7 +4,6 @@ use std::sync::{ atomic::{AtomicUsize, Ordering}, Arc, }; - use windmark::response::Response; #[windmark::main] diff --git a/rossweisse/src/implementations/router/parser/field_initializers.rs b/rossweisse/src/implementations/router/parser/field_initializers.rs index 81d171a..4ea44ad 100644 --- a/rossweisse/src/implementations/router/parser/field_initializers.rs +++ b/rossweisse/src/implementations/router/parser/field_initializers.rs @@ -1,6 +1,5 @@ -use syn::parse::{self, Parse}; - use super::field_initializer::FieldInitializer; +use syn::parse::{self, Parse}; pub struct FieldInitializers(pub Vec>); diff --git a/rustfmt.toml b/rustfmt.toml index dab6ef1..e78bcfa 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -16,7 +16,7 @@ newline_style = "Unix" normalize_comments = true normalize_doc_attributes = true reorder_impl_items = true -group_imports = "StdExternalCrate" +group_imports = "One" reorder_modules = true struct_field_align_threshold = 20 struct_lit_single_line = false diff --git a/src/handler.rs b/src/handler.rs index 2727823..47a6df9 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -1,9 +1,8 @@ -use async_trait::async_trait; - use crate::{ context::{ErrorContext, HookContext, RouteContext}, response::Response, }; +use async_trait::async_trait; #[async_trait] pub trait RouteResponse: Send + Sync { diff --git a/src/router.rs b/src/router.rs index 2207787..6f0a947 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1,5 +1,24 @@ #![allow(clippy::significant_drop_tightening)] +use crate::{ + context::{ErrorContext, HookContext, RouteContext}, + handler::{ + ErrorResponse, + Partial, + PostRouteHook, + PreRouteHook, + RouteResponse, + }, + module::{AsyncModule, Module}, + response::Response, + router_option::RouterOption, +}; +#[cfg(feature = "async-std")] +use async_std::{ + io::{ReadExt, WriteExt}, + sync::Mutex as AsyncMutex, +}; +use openssl::ssl::{self, SslAcceptor, SslMethod}; use std::{ collections::HashSet, error::Error, @@ -8,13 +27,6 @@ use std::{ sync::{Arc, Mutex}, time, }; - -#[cfg(feature = "async-std")] -use async_std::{ - io::{ReadExt, WriteExt}, - sync::Mutex as AsyncMutex, -}; -use openssl::ssl::{self, SslAcceptor, SslMethod}; #[cfg(feature = "tokio")] use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, @@ -22,20 +34,6 @@ use tokio::{ }; use url::Url; -use crate::{ - context::{ErrorContext, HookContext, RouteContext}, - handler::{ - ErrorResponse, - Partial, - PostRouteHook, - PreRouteHook, - RouteResponse, - }, - module::{AsyncModule, Module}, - response::Response, - router_option::RouterOption, -}; - macro_rules! block { ($body:expr) => { #[cfg(feature = "tokio")] diff --git a/src/router/tests.rs b/src/router/tests.rs index 77434f3..95db9c9 100644 --- a/src/router/tests.rs +++ b/src/router/tests.rs @@ -1,9 +1,7 @@ -use std::collections::HashSet; - -use matchit::Router as MatchRouter; - use super::{resolve_lookup_path, status_line}; use crate::{response::Response, router_option::RouterOption}; +use matchit::Router as MatchRouter; +use std::collections::HashSet; /// Resolve `request_path` against a router holding `routes`, under `options`. fn resolve( From 94814d52b012361aab7d57604f0a2679bcf1afe6 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Fri, 11 Sep 2026 06:50:28 +0000 Subject: [PATCH 06/40] test: Add compatibility coverage and move tests out of src --- Cargo.toml | 11 ++ rossweisse/Cargo.toml | 3 + rossweisse/src/lib.rs | 3 + src/router.rs | 1 + tests/consumer.rs | 64 +++++++ tests/consumer/Cargo.toml | 18 ++ tests/consumer/src/bin/async_context.rs | 1 + tests/consumer/src/bin/automatic_mime.rs | 1 + tests/consumer/src/bin/binary_context.rs | 3 + tests/consumer/src/bin/index_name.rs | 16 ++ tests/consumer/src/bin/valid.rs | 162 +++++++++++++++++ tests/consumer/src/bin/visibility.rs | 6 + src/router/tests.rs => tests/router.rs | 9 +- tests/router_runtime.rs | 219 +++++++++++++++++++++++ 14 files changed, 511 insertions(+), 6 deletions(-) create mode 100644 tests/consumer.rs create mode 100644 tests/consumer/Cargo.toml create mode 100644 tests/consumer/src/bin/async_context.rs create mode 100644 tests/consumer/src/bin/automatic_mime.rs create mode 100644 tests/consumer/src/bin/binary_context.rs create mode 100644 tests/consumer/src/bin/index_name.rs create mode 100644 tests/consumer/src/bin/valid.rs create mode 100644 tests/consumer/src/bin/visibility.rs rename src/router/tests.rs => tests/router.rs (97%) create mode 100644 tests/router_runtime.rs diff --git a/Cargo.toml b/Cargo.toml index e4d1af4..d935fb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = ["rossweisse"] [package] +autotests = false name = "windmark" version = "0.7.0" authors = ["Fuwn "] @@ -115,3 +116,13 @@ required-features = ["response-macros"] [[example]] name = "simple_async_std" required-features = ["async-std"] + +# Private unit-test modules also live in tests, so integration targets are explicit. +[[test]] +name = "binary_response" + +[[test]] +name = "consumer" + +[[test]] +name = "router_runtime" diff --git a/rossweisse/Cargo.toml b/rossweisse/Cargo.toml index bfbc98c..b6d287f 100644 --- a/rossweisse/Cargo.toml +++ b/rossweisse/Cargo.toml @@ -19,3 +19,6 @@ proc-macro = true [dependencies] quote = "1.0.26" # Quasi-quoting syn = { version = "2.0.15", features = ["full"] } # Source Code Parsing + +[dev-dependencies] +windmark = { path = "..", default-features = false } diff --git a/rossweisse/src/lib.rs b/rossweisse/src/lib.rs index 8cda9a2..024d315 100644 --- a/rossweisse/src/lib.rs +++ b/rossweisse/src/lib.rs @@ -62,6 +62,9 @@ pub fn router(arguments: TokenStream, item: TokenStream) -> TokenStream { /// use rossweisse::route; /// use windmark::response::Response; /// +/// # #[rossweisse::router] +/// # struct Router; +/// # /// #[rossweisse::router] /// impl Router { /// #[route] diff --git a/src/router.rs b/src/router.rs index 6f0a947..561e8c8 100644 --- a/src/router.rs +++ b/src/router.rs @@ -1184,4 +1184,5 @@ impl Default for Router { } #[cfg(test)] +#[path = "../tests/router.rs"] mod tests; diff --git a/tests/consumer.rs b/tests/consumer.rs new file mode 100644 index 0000000..9acdce2 --- /dev/null +++ b/tests/consumer.rs @@ -0,0 +1,64 @@ +use std::{path::PathBuf, process::Command}; + +#[test] +fn public_api_and_existing_macro_behavior_are_preserved() { + let manifest = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/consumer/Cargo.toml"); + let target = std::env::var_os("CARGO_TARGET_DIR") + .map_or_else( + || PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target"), + PathBuf::from, + ) + .join("compatibility-consumer"); + #[cfg(feature = "tokio")] + let runtime = "tokio"; + #[cfg(feature = "async-std")] + let runtime = "async-std"; + let cases = [ + ("valid", "", true), + ("automatic_mime", "requires the `auto-deduce-mime`", false), + ("binary_context", "cannot find value `context`", false), + ( + "async_context", + "cannot return reference to function parameter", + false, + ), + ("visibility", "struct `Capsule` is private", false), + ( + "index_name", + "no function or associated item named `index`", + false, + ), + ]; + + for (name, diagnostic, automatic_mime) in cases { + let features = if automatic_mime { + format!("{runtime},auto-deduce-mime") + } else { + runtime.to_owned() + }; + let output = Command::new(env!("CARGO")) + .arg(if diagnostic.is_empty() { + "run" + } else { + "check" + }) + .args(["--quiet", "--manifest-path"]) + .arg(&manifest) + .arg("--target-dir") + .arg(&target) + .args(["--features", &features, "--bin", name]) + .output() + .unwrap(); + let errors = String::from_utf8_lossy(&output.stderr); + + if diagnostic.is_empty() { + assert!(output.status.success(), "{name}: {errors}"); + } else { + assert!( + !output.status.success() && errors.contains(diagnostic), + "{name}: {errors}" + ); + } + } +} diff --git a/tests/consumer/Cargo.toml b/tests/consumer/Cargo.toml new file mode 100644 index 0000000..a04cad0 --- /dev/null +++ b/tests/consumer/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "windmark-compatibility-consumer" +version = "0.0.0" +edition = "2021" +publish = false + +[workspace] + +[features] +tokio = ["windmark/tokio", "dep:tokio"] +async-std = ["windmark/async-std"] +auto-deduce-mime = [] + +[dependencies] +windmark = { path = "../..", default-features = false, features = ["response-macros", "auto-deduce-mime"] } +rossweisse = { path = "../../rossweisse" } +async-trait = "0.1.68" +tokio = { version = "1.52.3", optional = true, features = ["macros", "rt"] } diff --git a/tests/consumer/src/bin/async_context.rs b/tests/consumer/src/bin/async_context.rs new file mode 100644 index 0000000..8e9a0cf --- /dev/null +++ b/tests/consumer/src/bin/async_context.rs @@ -0,0 +1 @@ +fn main() { let _ = windmark::success_async!(context, context.url.path()); } diff --git a/tests/consumer/src/bin/automatic_mime.rs b/tests/consumer/src/bin/automatic_mime.rs new file mode 100644 index 0000000..34a8aac --- /dev/null +++ b/tests/consumer/src/bin/automatic_mime.rs @@ -0,0 +1 @@ +fn main() { let _ = windmark::binary_success!(b"hello"); } diff --git a/tests/consumer/src/bin/binary_context.rs b/tests/consumer/src/bin/binary_context.rs new file mode 100644 index 0000000..c55d152 --- /dev/null +++ b/tests/consumer/src/bin/binary_context.rs @@ -0,0 +1,3 @@ +fn main() { + let _ = windmark::binary_success!(context, context.url.path().as_bytes()); +} diff --git a/tests/consumer/src/bin/index_name.rs b/tests/consumer/src/bin/index_name.rs new file mode 100644 index 0000000..ae19e01 --- /dev/null +++ b/tests/consumer/src/bin/index_name.rs @@ -0,0 +1,16 @@ +use rossweisse::route; + +#[rossweisse::router] +struct Capsule; + +#[rossweisse::router] +impl Capsule { + #[route(index)] + pub fn index( + _: windmark::context::RouteContext, + ) -> windmark::response::Response { + windmark::response::Response::success("index") + } +} + +fn main() { let _ = Capsule::index; } diff --git a/tests/consumer/src/bin/valid.rs b/tests/consumer/src/bin/valid.rs new file mode 100644 index 0000000..8bc30da --- /dev/null +++ b/tests/consumer/src/bin/valid.rs @@ -0,0 +1,162 @@ +use rossweisse::route; +use std::{cell::Cell, future::IntoFuture}; +use windmark::{ + context::{ErrorContext, HookContext, RouteContext}, + handler::{ + ErrorResponse, + Partial, + PostRouteHook, + PreRouteHook, + RouteResponse, + }, + module::{AsyncModule, Module}, + response::Response, + router::Router, + router_option::RouterOption, +}; + +#[rossweisse::router] +struct Empty; + +#[rossweisse::router] +impl Empty {} + +#[rossweisse::router(count = 7, count = 8, ignored = 9)] +struct Capsule { + count: usize, + label: String, +} + +#[rossweisse::router] +impl Capsule { + #[route(index)] + pub fn index(_: RouteContext) -> Response { Response::success("index") } +} + +struct Counter(Cell); + +impl Module for Counter { + fn on_pre_route(&mut self, _: &HookContext) { self.0.set(self.0.get() + 1); } +} + +struct AwaitableModule; + +#[async_trait::async_trait] +impl AsyncModule for AwaitableModule { + async fn on_pre_route(&mut self, _: &HookContext) {} +} + +struct Banner; + +impl Partial for Banner { + fn call(&self, _: &RouteContext) -> String { "banner".to_owned() } +} + +struct Observer; + +impl PreRouteHook for Observer { + fn call(&self, _: &HookContext) {} +} + +impl PostRouteHook for Observer { + fn call(&self, _: &HookContext, response: &mut Response) { + response.content.push('!'); + } +} + +struct Replies; + +#[async_trait::async_trait] +impl RouteResponse for Replies { + async fn call(&self, _: RouteContext) -> Response { + Response::success("route") + } +} + +#[async_trait::async_trait] +impl ErrorResponse for Replies { + async fn call(&self, _: ErrorContext) -> Response { + Response::not_found("missing") + } +} + +struct AwaitableResponse; + +impl IntoFuture for AwaitableResponse { + type IntoFuture = std::future::Ready; + type Output = Response; + + fn into_future(self) -> Self::IntoFuture { + std::future::ready(Response::success("awaitable")) + } +} + +struct Bytes; + +impl AsRef<[u8]> for Bytes { + fn as_ref(&self) -> &[u8] { b"bytes" } +} + +fn context() -> RouteContext { + RouteContext { + peer_address: None, + url: "gemini://localhost/path".parse().unwrap(), + parameters: Default::default(), + certificate: None, + } +} + +fn main() { + let mut router = Router::new(); + let capsule = Capsule::new(); + let _empty = Empty::new(); + let _attachment: fn(&mut Router, AwaitableModule) -> &mut Router = + Router::attach_async; + let _route: &dyn RouteResponse = &Replies; + let _error: &dyn ErrorResponse = &Replies; + + router.attach(Counter(Cell::new(0))); + router.add_header(Banner); + router.add_footer(Banner); + router.set_pre_route_callback(Observer); + router.set_post_route_callback(Observer); + router.mount("/awaitable", |_| AwaitableResponse); + router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); + assert_eq!(capsule.count, 7); + assert!(capsule.label.is_empty()); + assert_eq!(Capsule::__router_index(context()).content, "index"); + + let mut response = Response::input::("prompt".to_owned()); + + response.status = 79; + response.mime = Some("text/plain; charset=iso-8859-1".to_owned()); + response.character_set = Some("iso-8859-1".to_owned()); + response.languages = Some(vec!["en".to_owned()]); + response.binary_content = Some(vec![0xff]); + response.content = "metadata".to_owned(); + + assert!(response.serialize_body("header", "footer").is_empty()); + assert_eq!(Response::binary_success(Bytes, "text/plain").status, 21); + assert_eq!(Response::binary_success_auto(b"hello").status, 22); + assert_eq!(windmark::binary_success!(b"hello")(context()).status, 22); + assert_eq!( + windmark::binary_success_auto!(b"hello")(context()).status, + 22 + ); + assert_eq!( + windmark::binary_success!(request, request.url.path(), "text/plain")( + context() + ) + .status, + 21 + ); + assert_eq!( + windmark::success!(request, request.url.path())(context()).content, + "/path" + ); + + let body = "hello"; + let mime = "text/plain"; + + assert_eq!(windmark::binary_success!(body, mime)(context()).status, 21); +} diff --git a/tests/consumer/src/bin/visibility.rs b/tests/consumer/src/bin/visibility.rs new file mode 100644 index 0000000..189155e --- /dev/null +++ b/tests/consumer/src/bin/visibility.rs @@ -0,0 +1,6 @@ +mod capsule { + #[rossweisse::router] + pub struct Capsule; +} + +fn main() { let _: Option = None; } diff --git a/src/router/tests.rs b/tests/router.rs similarity index 97% rename from src/router/tests.rs rename to tests/router.rs index 95db9c9..a27f195 100644 --- a/src/router/tests.rs +++ b/tests/router.rs @@ -9,16 +9,16 @@ fn resolve( request_path: &str, routes: &[&str], ) -> String { - let mut router = MatchRouter::new(); + let mut matcher = MatchRouter::new(); for route in routes { - router.insert(*route, ()).unwrap(); + matcher.insert(*route, ()).unwrap(); } let option_set = options.iter().copied().collect::>(); resolve_lookup_path(&option_set, request_path, |candidate| { - router.at(candidate).is_ok() + matcher.at(candidate).is_ok() }) } @@ -133,7 +133,6 @@ fn success_status_line_uses_the_response_mime() { let mut response = Response::success("hi"); response.with_mime("text/plain"); - assert_eq!(line(&response), "20 text/plain; charset=utf-8; lang=en"); } @@ -142,7 +141,6 @@ fn success_status_line_uses_the_response_character_set() { let mut response = Response::success("hi"); response.with_character_set("iso-8859-1"); - assert_eq!( line(&response), "20 text/gemini; charset=iso-8859-1; lang=en" @@ -154,7 +152,6 @@ fn success_status_line_joins_the_response_languages() { let mut response = Response::success("hi"); response.with_languages(["en", "fr"]); - assert_eq!(line(&response), "20 text/gemini; charset=utf-8; lang=en,fr"); } diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs new file mode 100644 index 0000000..ebebf41 --- /dev/null +++ b/tests/router_runtime.rs @@ -0,0 +1,219 @@ +use openssl::{ + asn1::Asn1Time, + ec::{EcGroup, EcKey}, + hash::MessageDigest, + nid::Nid, + pkey::PKey, + ssl::{SslAcceptor, SslConnector, SslMethod, SslVerifyMode}, + x509::{X509NameBuilder, X509}, +}; +use std::{ + io::{Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +use windmark::{ + context::{HookContext, RouteContext}, + module::Module, + response::Response, + router::Router, + router_option::RouterOption, +}; + +type Events = Arc>>; + +struct Observer(Events); + +impl Module for Observer { + fn on_attach(&mut self, _: &mut Router) { + self.0.lock().unwrap().push("attach"); + } + + fn on_pre_route(&mut self, _: &HookContext) { + self.0.lock().unwrap().push("module-pre"); + } + + fn on_post_route(&mut self, _: &HookContext) { + self.0.lock().unwrap().push("module-post"); + } +} + +fn acceptor() -> SslAcceptor { + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap(); + let key = PKey::from_ec_key(EcKey::generate(&group).unwrap()).unwrap(); + let mut name = X509NameBuilder::new().unwrap(); + + name.append_entry_by_text("CN", "localhost").unwrap(); + + let name = name.build(); + let mut certificate = X509::builder().unwrap(); + + certificate.set_version(2).unwrap(); + certificate.set_subject_name(&name).unwrap(); + certificate.set_issuer_name(&name).unwrap(); + certificate.set_pubkey(&key).unwrap(); + certificate + .set_not_before(&Asn1Time::days_from_now(0).unwrap()) + .unwrap(); + certificate + .set_not_after(&Asn1Time::days_from_now(1).unwrap()) + .unwrap(); + certificate.sign(&key, MessageDigest::sha256()).unwrap(); + + let mut builder = + SslAcceptor::mozilla_intermediate(SslMethod::tls()).unwrap(); + + builder.set_certificate(&certificate.build()).unwrap(); + builder.set_private_key(&key).unwrap(); + builder.build() +} + +fn request(address: SocketAddr, path: &str, expected: &[u8]) { + let started = Instant::now(); + let stream = loop { + match TcpStream::connect_timeout(&address, Duration::from_millis(100)) { + Ok(stream) => break stream, + + Err(error) => { + assert!(started.elapsed() < Duration::from_secs(5), "{error}"); + std::thread::sleep(Duration::from_millis(10)); + } + } + }; + + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .unwrap(); + + let mut connector = SslConnector::builder(SslMethod::tls()).unwrap(); + + connector.set_verify(SslVerifyMode::NONE); + + let mut stream = connector.build().connect("localhost", stream).unwrap(); + let mut output = vec![0; expected.len()]; + + stream + .write_all(format!("gemini://localhost{path}\r\n").as_bytes()) + .unwrap(); + stream.read_exact(&mut output).unwrap(); + assert_eq!(output, expected); +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn callbacks_partials_and_case_folding_keep_their_existing_order() { + let reservation = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = reservation.local_addr().unwrap(); + let events: Events = Arc::default(); + let mut router = Router::new(); + + router.set_listener_address("127.0.0.1"); + router.set_port(address.port()); + router.set_ssl_acceptor(acceptor()); + router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); + router.attach(Observer(events.clone())); + + let recorded = events.clone(); + + router.set_pre_route_callback(move |_: &HookContext| { + recorded.lock().unwrap().push("callback-pre"); + }); + + let recorded = events.clone(); + + router.add_header(move |_: &RouteContext| { + recorded.lock().unwrap().push("header"); + "HEADER".to_owned() + }); + + let recorded = events.clone(); + + router.add_footer(move |_: &RouteContext| { + recorded.lock().unwrap().push("footer"); + "FOOTER".to_owned() + }); + + let recorded = events.clone(); + + router.mount("/users/:name", move |context: RouteContext| { + recorded.lock().unwrap().push("route"); + Response::success(context.parameters.get("name").unwrap()) + }); + + let recorded = events.clone(); + + router.mount("/binary", move |_| { + recorded.lock().unwrap().push("route"); + Response::binary_success([0, 0xff], "application/octet-stream") + }); + + let recorded = events.clone(); + + router.set_post_route_callback( + move |_: &HookContext, response: &mut Response| { + recorded.lock().unwrap().push("callback-post"); + response.content.push('!'); + }, + ); + drop(reservation); + + #[cfg(feature = "tokio")] + let server = tokio::spawn(async move { router.run().await.unwrap() }); + #[cfg(feature = "async-std")] + let server = + async_std::task::spawn(async move { router.run().await.unwrap() }); + #[cfg(feature = "tokio")] + let run_client = tokio::task::spawn_blocking; + #[cfg(feature = "async-std")] + let run_client = async_std::task::spawn_blocking; + let client = run_client(move || { + request( + address, + "/Users/Alice", + b"20 text/gemini; charset=utf-8; lang=en\r\nHEADER\nalice!\nFOOTER", + ); + request( + address, + "/binary", + b"20 application/octet-stream\r\n\x00\xff", + ); + }); + + #[cfg(feature = "tokio")] + client.await.unwrap(); + + #[cfg(feature = "async-std")] + client.await; + + #[cfg(feature = "tokio")] + server.abort(); + #[cfg(feature = "async-std")] + server.cancel().await; + assert_eq!( + *events.lock().unwrap(), + [ + "attach", + "module-pre", + "callback-pre", + "header", + "footer", + "route", + "module-post", + "callback-post", + "module-pre", + "callback-pre", + "header", + "footer", + "route", + "module-post", + "callback-post", + ] + ); +} From e7910fc6c43df85849936d67b5263d63bf825b0f Mon Sep 17 00:00:00 2001 From: Fuwn Date: Fri, 11 Sep 2026 06:51:24 +0000 Subject: [PATCH 07/40] refactor(rossweisse): Simplify router field generation and parsing --- .../src/implementations/router/fields.rs | 50 +++++++------------ .../src/implementations/router/parser.rs | 35 +++++++++++-- .../router/parser/field_initializer.rs | 22 -------- .../router/parser/field_initializers.rs | 10 ---- 4 files changed, 51 insertions(+), 66 deletions(-) delete mode 100644 rossweisse/src/implementations/router/parser/field_initializer.rs delete mode 100644 rossweisse/src/implementations/router/parser/field_initializers.rs diff --git a/rossweisse/src/implementations/router/fields.rs b/rossweisse/src/implementations/router/fields.rs index 8f7ee01..fb0d9fa 100644 --- a/rossweisse/src/implementations/router/fields.rs +++ b/rossweisse/src/implementations/router/fields.rs @@ -3,20 +3,18 @@ use quote::quote; use syn::punctuated::Punctuated; pub fn fields(arguments: TokenStream, item: syn::ItemStruct) -> TokenStream { - let field_initializers = syn::parse_macro_input!( - arguments as super::parser::FieldInitializers - ); + let field_initializers = + syn::parse_macro_input!(arguments as super::parser::FieldInitializers); let router_identifier = item.ident; - let (named_fields, has_fields) = match item.fields { - syn::Fields::Named(fields) => (fields, true), + let named_fields = match item.fields { + syn::Fields::Named(fields) => fields, + syn::Fields::Unit => - ( - syn::FieldsNamed { - brace_token: syn::token::Brace::default(), - named: Punctuated::default(), - }, - false, - ), + syn::FieldsNamed { + brace_token: syn::token::Brace::default(), + named: Punctuated::default(), + }, + syn::Fields::Unnamed(_) => panic!( "`#[rossweisse::router]` can only be used on `struct`s with named \ @@ -25,34 +23,24 @@ pub fn fields(arguments: TokenStream, item: syn::ItemStruct) -> TokenStream { }; let new_method_fields = named_fields.named.iter().map(|field| { let name = &field.ident; - let initialiser: syn::Expr = field_initializers + let initializer: syn::Expr = field_initializers .0 .iter() - .find(|initialiser| name.as_ref() == Some(&initialiser.ident)) + .find(|initializer| name.as_ref() == Some(&initializer.identifier)) .map_or_else( || syn::parse_quote! { ::std::default::Default::default() }, - |initialiser| initialiser.expr.clone(), + |initializer| initializer.expression.clone(), ); quote! { - #name: #initialiser, + #name: #initializer, } }); - let new_methods = if has_fields { - quote! { - fn _new() -> Self { - Self { - #(#new_method_fields)* - router: ::windmark::router::Router::new(), - } - } - } - } else { - quote! { - fn _new() -> Self { - Self { - router: ::windmark::router::Router::new(), - } + let new_methods = quote! { + fn _new() -> Self { + Self { + #(#new_method_fields)* + router: ::windmark::router::Router::new(), } } }; diff --git a/rossweisse/src/implementations/router/parser.rs b/rossweisse/src/implementations/router/parser.rs index 230b1d6..f08a01e 100644 --- a/rossweisse/src/implementations/router/parser.rs +++ b/rossweisse/src/implementations/router/parser.rs @@ -1,4 +1,33 @@ -mod field_initializer; -mod field_initializers; +use syn::{ + parse::{self, Parse}, + punctuated::Punctuated, + Expr, + Ident, + Token, +}; -pub use field_initializers::FieldInitializers; +pub struct FieldInitializer { + pub identifier: Ident, + pub expression: Expr, +} + +impl Parse for FieldInitializer { + fn parse(input: parse::ParseStream<'_>) -> syn::Result { + let identifier = input.parse()?; + let _: Token![=] = input.parse()?; + let expression = input.parse()?; + + Ok(Self { + identifier, + expression, + }) + } +} + +pub struct FieldInitializers(pub Punctuated); + +impl Parse for FieldInitializers { + fn parse(input: parse::ParseStream<'_>) -> syn::Result { + Punctuated::parse_terminated(input).map(Self) + } +} diff --git a/rossweisse/src/implementations/router/parser/field_initializer.rs b/rossweisse/src/implementations/router/parser/field_initializer.rs deleted file mode 100644 index fe51ce1..0000000 --- a/rossweisse/src/implementations/router/parser/field_initializer.rs +++ /dev/null @@ -1,22 +0,0 @@ -use syn::parse::{self, Parse}; - -pub struct FieldInitializer { - pub ident: syn::Ident, - #[allow(unused)] - eq_token: syn::Token![=], - pub expr: T, -} - -impl Parse for FieldInitializer { - fn parse(input: parse::ParseStream<'_>) -> syn::Result { - let ident = input.parse()?; - let eq_token = input.parse()?; - let expr = input.parse()?; - - Ok(Self { - ident, - eq_token, - expr, - }) - } -} diff --git a/rossweisse/src/implementations/router/parser/field_initializers.rs b/rossweisse/src/implementations/router/parser/field_initializers.rs deleted file mode 100644 index 4ea44ad..0000000 --- a/rossweisse/src/implementations/router/parser/field_initializers.rs +++ /dev/null @@ -1,10 +0,0 @@ -use super::field_initializer::FieldInitializer; -use syn::parse::{self, Parse}; - -pub struct FieldInitializers(pub Vec>); - -impl Parse for FieldInitializers { - fn parse(input: parse::ParseStream<'_>) -> syn::Result { - Ok(Self(syn::punctuated::Punctuated::, syn::Token![,]>::parse_terminated(input)?.into_iter().collect())) - } -} From dcb569c0e320736785c32d58e36f84af25fda24c Mon Sep 17 00:00:00 2001 From: Fuwn Date: Fri, 11 Sep 2026 06:51:24 +0000 Subject: [PATCH 08/40] refactor(router): Remove temporary footer and lookup allocations --- src/router.rs | 34 +++++++++++++++++++++---------- tests/router.rs | 53 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/router.rs b/src/router.rs index 561e8c8..f52dd58 100644 --- a/src/router.rs +++ b/src/router.rs @@ -199,12 +199,7 @@ impl RequestHandler { .expect("failed to write header"); } - footer = self - .footers - .iter() - .map(|partial_footer| partial_footer.call(&route_context)) - .collect::>() - .join("\n"); + footer = render_footer(&self.footers, &route_context); route.value.call(route_context).await } else { @@ -248,6 +243,23 @@ impl RequestHandler { } } +fn render_footer( + partials: &[Box], + context: &RouteContext, +) -> String { + let mut footer = String::new(); + + for (index, partial) in partials.iter().enumerate() { + if index != 0 { + footer.push('\n'); + } + + footer.push_str(&partial.call(context)); + } + + footer +} + /// Spawn a task to complete the TLS handshake on an accepted connection and /// dispatch it through `handler`. fn spawn_connection( @@ -337,11 +349,11 @@ fn resolve_lookup_path( request_path: &str, route_exists: impl Fn(&str) -> bool, ) -> String { - let mut path = request_path.to_string(); - - if options.contains(&RouterOption::AllowCaseInsensitiveLookup) { - path = path.to_lowercase(); - } + let path = if options.contains(&RouterOption::AllowCaseInsensitiveLookup) { + request_path.to_lowercase() + } else { + request_path.to_owned() + }; if route_exists(&path) { return path; diff --git a/tests/router.rs b/tests/router.rs index a27f195..3c02e64 100644 --- a/tests/router.rs +++ b/tests/router.rs @@ -185,3 +185,56 @@ fn failure_status_line_uses_only_the_first_line_of_content() { fn input_status_line_uses_its_prompt_as_the_meta() { assert_eq!(line(&Response::input("Your name?")), "10 Your name?"); } + +#[test] +fn footer_rendering_preserves_empty_values_and_callback_order() { + let context = crate::context::RouteContext { + peer_address: None, + url: "gemini://localhost/footer".parse().unwrap(), + parameters: crate::context::Parameters::default(), + certificate: None, + }; + + for values in [vec![], vec![""], vec!["", ""], vec!["a", "", "b\n"]] { + let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let partials: Vec> = values + .iter() + .enumerate() + .map(|(index, value)| { + let calls = calls.clone(); + let value = (*value).to_owned(); + + Box::new(move |context: &crate::context::RouteContext| { + assert_eq!(context.url.path(), "/footer"); + calls.lock().unwrap().push(index); + value.clone() + }) as Box + }) + .collect(); + + assert_eq!(super::render_footer(&partials, &context), values.join("\n")); + assert_eq!( + *calls.lock().unwrap(), + (0..values.len()).collect::>() + ); + } +} + +#[test] +fn case_folding_preserves_existing_unicode_and_probe_order() { + let options = [ + RouterOption::AllowCaseInsensitiveLookup, + RouterOption::AddMissingTrailingSlash, + ] + .into_iter() + .collect(); + let probes = std::cell::RefCell::new(Vec::new()); + let path = resolve_lookup_path(&options, "/Users/ÄLICE", |candidate| { + probes.borrow_mut().push(candidate.to_owned()); + + candidate == "/users/älice/" + }); + + assert_eq!(path, "/users/älice/"); + assert_eq!(*probes.borrow(), ["/users/älice", "/users/älice/"]); +} From d52a17f46ab18e49bf110304f4d57fefa6aedd58 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Fri, 11 Sep 2026 06:51:25 +0000 Subject: [PATCH 09/40] perf(response): Assemble responses in a single buffer --- src/response.rs | 40 +++++++++++++++++++++++++++------- src/router.rs | 24 +++++++++++++++----- tests/binary_response.rs | 21 ++++++++++++++++++ tests/router.rs | 47 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 13 deletions(-) diff --git a/src/response.rs b/src/response.rs index d2223be..f122899 100644 --- a/src/response.rs +++ b/src/response.rs @@ -107,21 +107,45 @@ impl Response { #[doc(hidden)] #[must_use] pub fn serialize_body(self, header: &str, footer: &str) -> Vec { + if matches!(self.status, 21 | 22) { + return self.binary_content.unwrap_or_default(); + } + + let mut body = Vec::with_capacity(self.body_length(header, footer)); + + self.append_body(&mut body, header, footer); + + body + } + + pub(crate) fn body_length(&self, header: &str, footer: &str) -> usize { match self.status { - 20 => { - let mut body = Vec::with_capacity( - header.len() + self.content.len() + footer.len() + 1, - ); + 20 => header.len() + self.content.len() + footer.len() + 1, + 21 | 22 => self.binary_content.as_ref().map_or(0, Vec::len), + _ => 0, + } + } + pub(crate) fn append_body( + &self, + body: &mut Vec, + header: &str, + footer: &str, + ) { + match self.status { + 20 => { body.extend_from_slice(header.as_bytes()); body.extend_from_slice(self.content.as_bytes()); body.push(b'\n'); body.extend_from_slice(footer.as_bytes()); - - body } - 21 | 22 => self.binary_content.unwrap_or_default(), - _ => Vec::new(), + + 21 | 22 => + if let Some(bytes) = &self.binary_content { + body.extend_from_slice(bytes); + }, + + _ => {} } } diff --git a/src/router.rs b/src/router.rs index f52dd58..afd100b 100644 --- a/src/router.rs +++ b/src/router.rs @@ -227,12 +227,8 @@ impl RequestHandler { let status_line = status_line(&content, &self.character_set, &self.languages_joined); - let body = content.serialize_body(&header, &footer); - let mut response = Vec::with_capacity(status_line.len() + body.len() + 2); + let response = serialize_response(content, &status_line, &header, &footer); - response.extend_from_slice(status_line.as_bytes()); - response.extend_from_slice(b"\r\n"); - response.extend_from_slice(&body); stream.write_all(&response).await?; #[cfg(feature = "tokio")] stream.shutdown().await?; @@ -243,6 +239,24 @@ impl RequestHandler { } } +fn serialize_response( + content: Response, + status_line: &str, + header: &str, + footer: &str, +) -> Vec { + let mut response = Vec::with_capacity( + status_line.len() + content.body_length(header, footer) + 2, + ); + + response.extend_from_slice(status_line.as_bytes()); + response.extend_from_slice(b"\r\n"); + content.append_body(&mut response, header, footer); + drop(content); + + response +} + fn render_footer( partials: &[Box], context: &RouteContext, diff --git a/tests/binary_response.rs b/tests/binary_response.rs index 47d3935..7422074 100644 --- a/tests/binary_response.rs +++ b/tests/binary_response.rs @@ -44,3 +44,24 @@ fn serialize_body_text_success_wraps_with_header_and_footer() { assert_eq!(body, b"HEADER\nbody\nFOOTER"); } + +#[test] +fn public_binary_serialization_returns_the_existing_allocation() { + for status in [21, 22] { + let mut response = Response::new(status, "ignored"); + let mut bytes = Vec::with_capacity(1024); + + bytes.extend_from_slice(NON_UTF8); + + let pointer = bytes.as_ptr(); + let capacity = bytes.capacity(); + + response.binary_content = Some(bytes); + + let body = response.serialize_body("ignored-header", "ignored-footer"); + + assert_eq!(body.as_ptr(), pointer); + assert_eq!(body.capacity(), capacity); + assert_eq!(body, NON_UTF8); + } +} diff --git a/tests/router.rs b/tests/router.rs index 3c02e64..3a299d2 100644 --- a/tests/router.rs +++ b/tests/router.rs @@ -238,3 +238,50 @@ fn case_folding_preserves_existing_unicode_and_probe_order() { assert_eq!(path, "/users/älice/"); assert_eq!(*probes.borrow(), ["/users/älice", "/users/älice/"]); } + +#[test] +fn response_assembly_preserves_publicly_constructible_states() { + for (status, expected_line, expected_body) in [ + ( + 20, + "20 text/gemini; charset=utf-8; lang=en", + b"HEADERbody\nFOOTER".as_slice(), + ), + (21, "20 ", b"\x00\xff".as_slice()), + (22, "20 ", b"\x00\xff".as_slice()), + (23, "23 body", b"".as_slice()), + (40, "40 body", b"".as_slice()), + (79, "79 body", b"".as_slice()), + (-1, "-1 body", b"".as_slice()), + ] { + let mut response = Response::new(status, "body"); + + response.binary_content = Some(vec![0, 0xff]); + + let status_line = line(&response); + let expected = [expected_line.as_bytes(), b"\r\n", expected_body].concat(); + + assert_eq!(status_line, expected_line); + assert_eq!( + response.clone().serialize_body("HEADER", "FOOTER"), + expected_body + ); + assert_eq!( + super::serialize_response(response, &status_line, "HEADER", "FOOTER"), + expected + ); + } + + for status in [21, 22] { + let response = Response::new(status, "ignored"); + + assert!(response + .clone() + .serialize_body("HEADER", "FOOTER") + .is_empty()); + assert_eq!( + super::serialize_response(response, "20 ", "HEADER", "FOOTER"), + b"20 \r\n" + ); + } +} From 2783208cf0575ea36573d90687a232153205e9bd Mon Sep 17 00:00:00 2001 From: Fuwn Date: Fri, 11 Sep 2026 06:51:25 +0000 Subject: [PATCH 10/40] refactor(examples): Simplify state and response handling --- examples/async_stateful_module.rs | 11 ++++++----- examples/callbacks.rs | 2 +- examples/error_handler.rs | 3 ++- examples/mime.rs | 8 +++++--- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/examples/async_stateful_module.rs b/examples/async_stateful_module.rs index 6391611..51e9cb1 100644 --- a/examples/async_stateful_module.rs +++ b/examples/async_stateful_module.rs @@ -4,7 +4,7 @@ use windmark::{context::HookContext, router::Router}; #[derive(Default)] struct Clicker { - clicks: std::sync::Arc>, + clicks: usize, } #[async_trait::async_trait] @@ -14,12 +14,12 @@ impl windmark::module::AsyncModule for Clicker { } async fn on_pre_route(&mut self, context: &HookContext) { - *self.clicks.lock().unwrap() += 1; + self.clicks += 1; println!( "module 'clicker' has been called before the route '{}' with {} clicks!", context.url.path(), - self.clicks.lock().unwrap() + self.clicks ); } @@ -28,7 +28,7 @@ impl windmark::module::AsyncModule for Clicker { "module 'clicker' clicker has been called after the route '{}' with {} \ clicks!", context.url.path(), - self.clicks.lock().unwrap() + self.clicks ); } } @@ -39,12 +39,13 @@ async fn main() -> Result<(), Box> { router.set_private_key_file("windmark_private.pem"); router.set_certificate_file("windmark_public.pem"); + #[cfg(feature = "logger")] { router.enable_default_logger(true); } + router.attach_async(Clicker::default()); router.mount("/", windmark::success!("Hello!")); - router.run().await } diff --git a/examples/callbacks.rs b/examples/callbacks.rs index c36d16a..a8c153c 100644 --- a/examples/callbacks.rs +++ b/examples/callbacks.rs @@ -12,7 +12,7 @@ async fn main() -> Result<(), Box> { println!( "accepted connection from {} to {}", context.peer_address.unwrap().ip(), - context.url.to_string() + context.url ) }) .set_post_route_callback( diff --git a/examples/error_handler.rs b/examples/error_handler.rs index 74a70a2..3fd18c6 100644 --- a/examples/error_handler.rs +++ b/examples/error_handler.rs @@ -6,6 +6,7 @@ use std::sync::{ }; use windmark::response::Response; +#[allow(clippy::unnecessary_literal_unwrap)] #[windmark::main] async fn main() -> Result<(), Box> { let error_count = Arc::new(AtomicUsize::new(0)); @@ -17,12 +18,12 @@ async fn main() -> Result<(), Box> { let count = error_count.fetch_add(1, Ordering::Relaxed) + 1; println!("{count} errors so far"); - Response::permanent_failure("e") }) .mount("/error", |_| { let nothing = None::; + // This deliberately panics without invoking the unmatched-route handler. Response::success(nothing.unwrap()) }) .run() diff --git a/examples/mime.rs b/examples/mime.rs index b61c844..7544109 100644 --- a/examples/mime.rs +++ b/examples/mime.rs @@ -6,9 +6,11 @@ async fn main() -> Result<(), Box> { .set_private_key_file("windmark_private.pem") .set_certificate_file("windmark_public.pem") .mount("/mime", |_| { - windmark::response::Response::success("Hello!".to_string()) - .with_mime("text/plain") - .clone() + let mut response = windmark::response::Response::success("Hello!"); + + response.with_mime("text/plain"); + + response }) .run() .await From b9f1e0660319b15c81c4d7ce5614e0c8a49eb34a Mon Sep 17 00:00:00 2001 From: Fuwn Date: Fri, 11 Sep 2026 06:51:25 +0000 Subject: [PATCH 11/40] ci: Check formatting and test the supported feature combinations --- .github/workflows/check.yaml | 50 +++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index eebe20a..7ed1bb7 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -9,27 +9,37 @@ env: CARGO_TERM_COLOR: always jobs: - check: + format: runs-on: ubuntu-latest steps: - - name: Checkout 🛒 - uses: actions/checkout@v4 - - - name: Toolchain 🧰 - uses: dtolnay/rust-toolchain@nightly + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly-2026-05-25 with: - components: rustfmt, clippy - - - name: Just 📋 - uses: extractions/setup-just@v2 - - - name: Format, check, and lint ✅ - run: just checkfc - - - name: Verify formatting 🎨 - run: git diff --exit-code - - - name: Test 🧪 + components: rustfmt + - name: Check Formatting run: | - just test tokio - just test async-std + cargo +nightly-2026-05-25 fmt --all -- --check + cargo +nightly-2026-05-25 fmt --manifest-path tests/consumer/Cargo.toml -- --check + + check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: Default + flags: --features windmark/tokio + - name: Tokio + flags: --no-default-features --features windmark/tokio,windmark/logger,windmark/auto-deduce-mime,windmark/response-macros + - name: Async-Std + flags: --no-default-features --features windmark/async-std,windmark/logger,windmark/auto-deduce-mime,windmark/response-macros + name: Check ${{ matrix.name }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.95.0 + with: + components: clippy + - name: Test the Workspace and Consumers + run: cargo +1.95.0 test --workspace ${{ matrix.flags }} + - name: Lint All Targets + run: cargo +1.95.0 clippy --workspace --all-targets ${{ matrix.flags }} -- -D warnings From 34cad5d46de422b8e7595f49959bfdf215b9a5e6 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Fri, 11 Sep 2026 06:51:25 +0000 Subject: [PATCH 12/40] chore: Remove inactive formatter options and macro comments --- rustfmt.toml | 2 -- src/response/macros.rs | 10 ++++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/rustfmt.toml b/rustfmt.toml index e78bcfa..aa979af 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,8 +1,6 @@ condense_wildcard_suffixes = true edition = "2021" enum_discrim_align_threshold = 20 -# error_on_line_overflow = true -# error_on_unformatted = true fn_single_line = true force_multiline_blocks = true format_code_in_doc_comments = true diff --git a/src/response/macros.rs b/src/response/macros.rs index 5a86eb1..566ad1c 100644 --- a/src/response/macros.rs +++ b/src/response/macros.rs @@ -4,10 +4,10 @@ macro_rules! sync_response { /// Trailing commas are not supported at the moment! #[macro_export] macro_rules! $name { - ($body:expr /* $(,)? */) => { + ($body:expr) => { |_: $crate::context::RouteContext| $crate::response::Response::$name($body) }; - ($context:ident, $body:expr /* $(,)? */) => { + ($context:ident, $body:expr) => { |$context: $crate::context::RouteContext| $crate::response::Response::$name($body) }; } @@ -21,10 +21,10 @@ macro_rules! async_response { /// Trailing commas are not supported at the moment! #[macro_export] macro_rules! [< $name _async >] { - ($body:expr /* $(,)? */) => { + ($body:expr) => { |_: $crate::context::RouteContext| async { $crate::response::Response::$name($body) } }; - ($context:ident, $body:expr /* $(,)? */) => { + ($context:ident, $body:expr) => { |$context: $crate::context::RouteContext| async { $crate::response::Response::$name($body) } }; } @@ -84,7 +84,6 @@ macro_rules! binary_success { #[cfg(feature = "auto-deduce-mime")] return $crate::response::Response::binary_success_auto($body); - // Suppress item not found warning #[cfg(not(feature = "auto-deduce-mime"))] $crate::response::Response::binary_success( $body, @@ -108,7 +107,6 @@ macro_rules! binary_success { #[cfg(feature = "auto-deduce-mime")] return $crate::response::Response::binary_success_auto($body); - // Suppress item not found warning #[cfg(not(feature = "auto-deduce-mime"))] $crate::response::Response::binary_success( $body, From 7fa2dad02221695f6e7831fdddd51e5e539f5b21 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Fri, 11 Sep 2026 06:51:59 +0000 Subject: [PATCH 13/40] style(router): Apply statement grouping consistently --- src/router.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/router.rs b/src/router.rs index afd100b..4e44751 100644 --- a/src/router.rs +++ b/src/router.rs @@ -124,7 +124,6 @@ impl RequestHandler { let mut footer = String::new(); let mut header = String::new(); let mut request = String::new(); - let mut url = loop { let size = match stream.read(&mut buffer).await { Ok(0) | Err(_) => return Ok(()), @@ -166,7 +165,6 @@ impl RequestHandler { self.routes.at(candidate).is_ok() }); let route = self.routes.at(&route_path); - let peer_certificate = stream.ssl().peer_certificate(); let hook_context = HookContext::new( stream.get_ref().peer_addr(), @@ -174,6 +172,7 @@ impl RequestHandler { route.as_ref().ok().map(|route| route.params.clone()), peer_certificate.clone(), ); + for module in &mut *self.async_modules.lock().await { module.on_pre_route(&hook_context).await; } @@ -234,7 +233,6 @@ impl RequestHandler { stream.shutdown().await?; #[cfg(feature = "async-std")] stream.get_mut().shutdown(std::net::Shutdown::Both)?; - Ok(()) } } @@ -295,7 +293,6 @@ fn spawn_connection( return; } }; - #[cfg(feature = "tokio")] let quick_stream = tokio_openssl::SslStream::new(ssl, stream); #[cfg(feature = "async-std")] @@ -653,7 +650,6 @@ impl Router { modules: self.modules.clone(), options: self.options.clone(), }); - let acceptor = self.ssl_acceptor.clone(); // Under Tokio, accept connections until interrupted (Ctrl+C / SIGINT) so @@ -1026,7 +1022,6 @@ impl Router { mut module: impl Module + 'static + Send, ) -> &mut Self { module.on_attach(self); - (*self.modules.lock().expect("modules lock poisoned")) .push(Box::new(module)); From ea23c647a07f944898768f68942a0924f142c6b7 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Fri, 11 Sep 2026 06:51:59 +0000 Subject: [PATCH 14/40] docs: Correct API guidance and explain examples and tests --- README.md | 12 ++++--- examples/README.md | 17 +++++---- examples/callbacks.rs | 2 +- rossweisse/README.md | 5 +-- rossweisse/src/lib.rs | 5 ++- src/module.rs | 18 ++++++---- src/response.rs | 15 ++++---- src/router.rs | 82 +++++++++++++++++++++++-------------------- src/router_option.rs | 2 +- src/utilities.rs | 5 +-- tests/README.md | 27 ++++++++++++++ 11 files changed, 117 insertions(+), 73 deletions(-) create mode 100644 tests/README.md diff --git a/README.md b/README.md index a8a8c37..bd4dc77 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,8 @@ Now supporting both [Tokio](https://tokio.rs/) and [`async-std`](https://async.r ## Usage > [!NOTE] -> A macro-based "`struct`-router" is in active development as a simplified -> alternative to the standard server creation approach. Check out -> [Rossweisse](./rossweisse/) for more information! +> Rossweisse lets you define a Windmark router using a struct and route +> attributes. See the [Rossweisse guide](./rossweisse/) for an example. ### Features @@ -73,6 +72,7 @@ async fn main() -> Result<(), Box> { ```rust // src/main.rs +use rossweisse::route; use windmark::response::Response; #[rossweisse::router] @@ -80,7 +80,7 @@ struct Router; #[rossweisse::router] impl Router { - #[rossweisse::route(index)] + #[route(index)] pub fn index( _context: windmark::context::RouteContext, ) -> Response { @@ -97,7 +97,9 @@ Examples can be found within the [`examples/`](https://github.com/gemrest/windmark/tree/main/examples) directory along with a rundown of each of their purposes and useful facts. -Run an example by cloning this repository and running `cargo run --example example_name`. +Each entry in [the examples guide](./examples/README.md) lists the required +features. Generate local credentials with `just gen-key`, then use the listed +command or `just example example_name` (optionally followed by `async-std`). ## Modules diff --git a/examples/README.md b/examples/README.md index 8b3fb51..d3b23d1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,9 +14,9 @@ tracked for clicks. `cargo run --example async --features response-macros` -Demonstrates use of async routes through an async response macro and -implementing a click tracker using a shared variable through an thread-safe, -async mutex. +This example shows how to define async routes with an async response macro +and implement a click tracker using a shared variable protected by an async +mutex. ## [Binary](./binary.rs) @@ -29,7 +29,7 @@ and automatic mime resolution (`--features auto-deduce-mime`). `cargo run --example callbacks --features response-macros` -Demonstrates use of the pre and post-route callback handlers. +This example shows how to register pre-route and post-route callbacks. ## [Certificate](./certificate.rs) @@ -42,7 +42,7 @@ reading the client certificate to give conditional access. `cargo run --example default_logger --features logger,response-macros` -A simple example showing the use of the default default logger implementation. +A simple example showing the use of the default logger implementation. ## [Empty](./empty.rs) @@ -54,7 +54,9 @@ An empty example which starts up a server but has no mounted routes. `cargo run --example error_handler` -Creates an intentional error within a route, invoking the error handler. +This example shows how to handle requests that do not match a route. It also +includes an `/error` route that deliberately panics; this panic does not invoke +the error handler. ## [Fix Path](./fix_path.rs) @@ -109,7 +111,8 @@ Demonstrates the use of a wide variety of responses, additionally exposing the f Demonstrates how to explicitly specify Windmark to use the [`async-std`](https://github.com/async-rs/async-std) runtime. -If the `async-std` feature is NOT enabled, Windmark will default to using Tokio as the async runtime. +Windmark uses Tokio when its default features are enabled. If you disable +default features, you must explicitly enable either `tokio` or `async-std`. ## [Simple Tokio](./simple_tokio.rs) diff --git a/examples/callbacks.rs b/examples/callbacks.rs index a8c153c..b6b4e8b 100644 --- a/examples/callbacks.rs +++ b/examples/callbacks.rs @@ -20,7 +20,7 @@ async fn main() -> Result<(), Box> { content.content = content.content.replace("Hello", "Hi"); println!( - "closed connection from {}", + "prepared response for {}", context.peer_address.unwrap().ip() ) }, diff --git a/rossweisse/README.md b/rossweisse/README.md index ef35bb5..ee47bf8 100644 --- a/rossweisse/README.md +++ b/rossweisse/README.md @@ -4,9 +4,10 @@ ## Usage -Rossweisse is in it's infancy, and a much comprehensive interface is planned. +Rossweisse provides a struct wrapper around Windmark. Routes are associated +functions and do not receive `self`. -For now, a simple Rosswiesse router can be implemented like this: +For now, a simple Rossweisse router can be implemented like this: ```rust use rossweisse::route; diff --git a/rossweisse/src/lib.rs b/rossweisse/src/lib.rs index 024d315..9589435 100644 --- a/rossweisse/src/lib.rs +++ b/rossweisse/src/lib.rs @@ -15,8 +15,7 @@ mod implementations; use proc_macro::TokenStream; use syn::Item; -/// Marks a `struct` as a router or marks an `impl` block as a router -/// implementation +/// Mark a `struct` as a router or an `impl` block as a router implementation. /// /// # Panics /// @@ -50,7 +49,7 @@ pub fn router(arguments: TokenStream, item: TokenStream) -> TokenStream { } } -/// Marks a method of a router implementation as a route to mount +/// Mark a method of a router implementation as a route to mount. /// /// # Panics /// diff --git a/src/module.rs b/src/module.rs index 49991fc..612e621 100644 --- a/src/module.rs +++ b/src/module.rs @@ -1,24 +1,30 @@ use crate::context::HookContext; pub trait Module { - /// Called right after the module is attached. + /// The router calls this hook during attachment, before registering + /// request hooks. fn on_attach(&mut self, _: &mut crate::router::Router) {} - /// Called before a route is mounted. + /// The router calls this hook before dispatching a request, even if the + /// request does not match a route. fn on_pre_route(&mut self, _: &HookContext) {} - /// Called after a route is mounted. + /// The router calls this hook after the handler completes, before + /// response serialisation. fn on_post_route(&mut self, _: &HookContext) {} } #[async_trait::async_trait] pub trait AsyncModule: Send + Sync { - /// Called right after the module is attached. + /// The router calls this hook during attachment, before registering + /// request hooks. async fn on_attach(&mut self, _: &mut crate::router::Router) {} - /// Called before a route is mounted. + /// The router calls this hook before dispatching a request, even if the + /// request does not match a route. async fn on_pre_route(&mut self, _: &HookContext) {} - /// Called after a route is mounted. + /// The router calls this hook after the handler completes, before + /// response serialisation. async fn on_post_route(&mut self, _: &HookContext) {} } diff --git a/src/response.rs b/src/response.rs index f122899..c0b7f95 100644 --- a/src/response.rs +++ b/src/response.rs @@ -1,4 +1,4 @@ -//! Content and response handlers +//! This module provides the response type returned by handlers. #[cfg(feature = "response-macros")] mod macros; @@ -12,15 +12,16 @@ macro_rules! response { }; } -/// The content and response type a handler should reply with. +/// A response holds the status and content returned by a handler. #[derive(Clone)] #[non_exhaustive] pub struct Response { - pub status: i32, - pub mime: Option, - pub content: String, - /// Raw body for status `21`/`22`; the router emits these bytes verbatim - /// instead of `content`. + pub status: i32, + pub mime: Option, + pub content: String, + + /// The router emits these bytes verbatim instead of `content` for status + /// `21`/`22`. pub binary_content: Option>, pub character_set: Option, pub languages: Option>, diff --git a/src/router.rs b/src/router.rs index 4e44751..20629c2 100644 --- a/src/router.rs +++ b/src/router.rs @@ -70,8 +70,7 @@ type Stream = tokio_openssl::SslStream; #[cfg(feature = "async-std")] type Stream = async_std_openssl::SslStream; -/// A router which takes care of all tasks a Windmark server should handle: -/// response generation, panics, logging, and more. +/// A router dispatches requests through its handlers, partials, and modules. #[derive(Clone)] pub struct Router { routes: matchit::Router>, @@ -393,7 +392,7 @@ fn resolve_lookup_path( } impl Router { - /// Create a new `Router` + /// Create a new `Router`. /// /// # Examples /// @@ -423,7 +422,7 @@ impl Router { self } - /// Set the content of the private key + /// Set the content of the private key. /// /// # Examples /// @@ -471,9 +470,9 @@ impl Router { self } - /// Map routes to URL paths + /// Map routes to URL paths. /// - /// Supports both synchronous and asynchronous handlers + /// This method supports both synchronous and asynchronous handlers. /// /// # Examples /// @@ -510,7 +509,8 @@ impl Router { self } - /// Create an error handler which will be displayed on any error. + /// Set the handler for unmatched routes. Transport errors and route panics + /// do not invoke this handler. /// /// # Examples /// @@ -559,7 +559,7 @@ impl Router { /// /// # Panics /// - /// May panic if the header cannot be added. + /// This method panics if the footer registry lock has been poisoned. /// /// # Examples /// @@ -577,7 +577,7 @@ impl Router { self } - /// Run the `Router` and wait for requests + /// Run the `Router` and wait for requests. /// /// Under the default Tokio runtime, the server runs until it receives an /// interrupt (Ctrl+C / SIGINT), at which point it stops accepting new @@ -652,9 +652,9 @@ impl Router { }); let acceptor = self.ssl_acceptor.clone(); - // Under Tokio, accept connections until interrupted (Ctrl+C / SIGINT) so - // the server can shut down gracefully; `async-std` has no built-in signal - // handling, so it keeps the original unconditional accept loop. + // Under Tokio, Ctrl+C stops the server from accepting new connections + // without waiting for active connection tasks to finish. The async-std + // runtime continues accepting connections until the process exits. #[cfg(feature = "tokio")] { loop { @@ -727,7 +727,7 @@ impl Router { Ok(()) } - /// Use a self-made `SslAcceptor` + /// Use a custom `SslAcceptor`. /// /// # Examples /// @@ -756,7 +756,7 @@ impl Router { self } - /// Enabled the default logger (the + /// Enable the default logger (the /// [`pretty_env_logger`](https://crates.io/crates/pretty_env_logger) and /// [`log`](https://crates.io/crates/log) crates). #[cfg(feature = "logger")] @@ -773,7 +773,7 @@ impl Router { /// Set the default logger's log level. /// /// If you enable Windmark's default logger with `enable_default_logger`, - /// Windmark will only log, logs from itself. By setting a log level with + /// Windmark will only emit its own logs. By setting a log level with /// this method, you are overriding the default log level, so you must choose /// to enable logs from Windmark with the `log_windmark` parameter. /// @@ -786,7 +786,7 @@ impl Router { /// windmark::router::Router::new() /// .enable_default_logger(true) /// .set_log_level("your_crate_name=trace", true); - /// // If you would only like to log, logs from your crate: + /// // Use this setting to emit only logs from your crate. /// // .set_log_level("your_crate_name=trace", false); /// ``` #[cfg(feature = "logger")] @@ -804,7 +804,8 @@ impl Router { self } - /// Set a callback to run before a client response is delivered + /// Set a callback to run before a request is dispatched, even if the request + /// does not match a route. /// /// # Examples /// @@ -829,7 +830,8 @@ impl Router { self } - /// Set a callback to run after a client response is delivered + /// Set a callback to modify the response after the handler, before + /// serialisation and delivery. /// /// # Examples /// @@ -905,13 +907,13 @@ impl Router { self } - /// Attach a stateful module to a `Router`; with async support + /// Attach a stateful module, completing its asynchronous initialisation + /// before returning. Tokio requires an active multi-threaded runtime. /// - /// Like a stateless module is an extension or middleware to a `Router`. - /// Modules get full access to the `Router` and can be extended by a third - /// party, but also, can create hooks will be executed through various parts - /// of a routes' lifecycle. Stateful modules also have state, so variables can - /// be stored for further access. + /// Like a stateless module, a stateful module extends a `Router` and gets + /// full access to it during attachment. Third parties can provide modules + /// with hooks that run during a request's lifecycle. Stateful modules retain + /// state between hook calls. /// /// # Panics /// @@ -953,7 +955,10 @@ impl Router { /// } /// } /// - /// // Router::new().attach_async(Clicker::default()); + /// # #[windmark::main] + /// # async fn main() { + /// Router::new().attach_async(Clicker::default()); + /// # } /// ``` pub fn attach_async( &mut self, @@ -970,11 +975,10 @@ impl Router { /// Attach a stateful module to a `Router`. /// - /// Like a stateless module is an extension or middleware to a `Router`. - /// Modules get full access to the `Router` and can be extended by a third - /// party, but also, can create hooks will be executed through various parts - /// of a routes' lifecycle. Stateful modules also have state, so variables can - /// be stored for further access. + /// Like a stateless module, a stateful module extends a `Router` and gets + /// full access to it during attachment. Third parties can provide modules + /// with hooks that run during a request's lifecycle. Stateful modules retain + /// state between hook calls. /// /// # Panics /// @@ -1030,9 +1034,9 @@ impl Router { /// Specify a custom character set. /// - /// Will be over-ridden if a character set is specified in a [`Response`]. + /// A character set specified in a [`Response`] overrides this setting. /// - /// Defaults to `"utf-8"`. + /// The default character set is `"utf-8"`. /// /// # Examples /// @@ -1050,9 +1054,9 @@ impl Router { /// Specify a custom language. /// - /// Will be over-ridden if a language is specified in a [`Response`]. + /// Languages specified in a [`Response`] override this setting. /// - /// Defaults to `"en"`. + /// The default language is `"en"`. /// /// # Examples /// @@ -1072,7 +1076,7 @@ impl Router { /// Specify a custom port. /// - /// Defaults to `1965`. + /// The default port is `1965`. /// /// # Examples /// @@ -1085,7 +1089,7 @@ impl Router { self } - /// Add optional features to the router + /// Add optional features to the router. /// /// # Examples /// @@ -1103,7 +1107,7 @@ impl Router { self } - /// Toggle optional features for the router + /// Toggle optional features for the router. /// /// # Examples /// @@ -1125,7 +1129,7 @@ impl Router { self } - /// Remove optional features from the router + /// Remove optional features from the router. /// /// # Examples /// @@ -1145,7 +1149,7 @@ impl Router { /// Specify a custom listener address. /// - /// Defaults to `"0.0.0.0"`. + /// The default listener address is `"0.0.0.0"`. /// /// # Examples /// diff --git a/src/router_option.rs b/src/router_option.rs index a28ba6f..d555763 100644 --- a/src/router_option.rs +++ b/src/router_option.rs @@ -1,4 +1,4 @@ -/// Options that can be set for the `Router` +/// These options configure the `Router`. #[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)] pub enum RouterOption { /// If enabled, removes a trailing slash from the request URL path if a route diff --git a/src/utilities.rs b/src/utilities.rs index 8b4af15..1e8c78b 100644 --- a/src/utilities.rs +++ b/src/utilities.rs @@ -1,8 +1,9 @@ -//! Utilities to make cumbersome tasks simpler +//! This module provides URL query helpers. use std::collections::HashMap; -/// Extract the queries from a URL into a `HashMap`. +/// Extract decoded query pairs into a map. The last value wins for repeated +/// keys; use `Url::query_pairs` directly to retain repeated keys. #[must_use] pub fn queries_from_url(url: &url::Url) -> HashMap { let mut queries = HashMap::new(); diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..de641c9 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,27 @@ +# Tests + +Run the complete workspace with an explicitly selected runtime: + +```sh +cargo test --workspace --features windmark/tokio +cargo test --workspace --no-default-features --features windmark/tokio,windmark/logger,windmark/auto-deduce-mime,windmark/response-macros +cargo test --workspace --no-default-features --features windmark/async-std,windmark/logger,windmark/auto-deduce-mime,windmark/response-macros +``` + +The qualified runtime feature also selects the runtime for Rossweisse's +Windmark development dependency. + +## Layout + +`router.rs` is loaded as a private unit-test module to exercise internal +helpers without publishing them. Integration targets are explicitly listed in +`Cargo.toml`, so Cargo does not also compile that file as a separate crate. + +## Consumer Compatibility + +`consumer.rs` checks the independent crate in `consumer/`. + +The failing fixtures preserve known API limitations so changes to that +behaviour are deliberate. + +Format both the workspace and the independent consumer crate. From a016ffb644d439daa45dd45c643b74c48b90fb77 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Fri, 11 Sep 2026 07:30:13 +0000 Subject: [PATCH 15/40] fix(router): Decode complete requests and terminate error responses --- src/router.rs | 22 ++++--- tests/router_runtime.rs | 130 ++++++++++++++++++++++++++++++---------- 2 files changed, 111 insertions(+), 41 deletions(-) diff --git a/src/router.rs b/src/router.rs index 20629c2..f3106d7 100644 --- a/src/router.rs +++ b/src/router.rs @@ -122,18 +122,14 @@ impl RequestHandler { let mut buffer = [0u8; 1024]; let mut footer = String::new(); let mut header = String::new(); - let mut request = String::new(); + let mut request = Vec::new(); let mut url = loop { let size = match stream.read(&mut buffer).await { Ok(0) | Err(_) => return Ok(()), Ok(size) => size, }; - request.push_str(or_error!( - stream, - std::str::from_utf8(&buffer[0..size]), - "59 The server (Windmark) received a bad request: {}" - )); + request.extend_from_slice(&buffer[..size]); if request.len() > 1024 { stream @@ -146,11 +142,19 @@ impl RequestHandler { return Ok(()); } - if let Some(position) = request.find("\r\n") { + if let Some(position) = + request.windows(2).position(|pair| pair == b"\r\n") + { + let request = or_error!( + stream, + std::str::from_utf8(&request[..position]), + "59 The server (Windmark) received a bad request: {}\r\n" + ); + break or_error!( stream, - Url::parse(&request[..position]), - "59 The server (Windmark) received a bad request: {}" + Url::parse(request), + "59 The server (Windmark) received a bad request: {}\r\n" ); } }; diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs index ebebf41..9ea1bc5 100644 --- a/tests/router_runtime.rs +++ b/tests/router_runtime.rs @@ -70,6 +70,14 @@ fn acceptor() -> SslAcceptor { } fn request(address: SocketAddr, path: &str, expected: &[u8]) { + request_chunks( + address, + &[format!("gemini://localhost{path}\r\n").as_bytes()], + expected, + ); +} + +fn request_chunks(address: SocketAddr, chunks: &[&[u8]], expected: &[u8]) { let started = Instant::now(); let stream = loop { match TcpStream::connect_timeout(&address, Duration::from_millis(100)) { @@ -96,27 +104,59 @@ fn request(address: SocketAddr, path: &str, expected: &[u8]) { let mut stream = connector.build().connect("localhost", stream).unwrap(); let mut output = vec![0; expected.len()]; - stream - .write_all(format!("gemini://localhost{path}\r\n").as_bytes()) - .unwrap(); + for chunk in chunks { + stream.write_all(chunk).unwrap(); + } + stream.read_exact(&mut output).unwrap(); assert_eq!(output, expected); } +async fn serve_requests( + mut router: Router, + client: impl FnOnce(SocketAddr) + Send + 'static, +) { + let reservation = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = reservation.local_addr().unwrap(); + + router.set_listener_address("127.0.0.1"); + router.set_port(address.port()); + router.set_ssl_acceptor(acceptor()); + drop(reservation); + + #[cfg(feature = "tokio")] + let server = tokio::spawn(async move { router.run().await.unwrap() }); + #[cfg(feature = "async-std")] + let server = + async_std::task::spawn(async move { router.run().await.unwrap() }); + #[cfg(feature = "tokio")] + let run_client = tokio::task::spawn_blocking; + #[cfg(feature = "async-std")] + let run_client = async_std::task::spawn_blocking; + let client = run_client(move || client(address)); + #[cfg(feature = "tokio")] + let result = client.await; + + #[cfg(feature = "async-std")] + client.await; + + #[cfg(feature = "tokio")] + server.abort(); + #[cfg(feature = "async-std")] + server.cancel().await; + #[cfg(feature = "tokio")] + result.unwrap(); +} + #[cfg_attr( feature = "tokio", tokio::test(flavor = "multi_thread", worker_threads = 2) )] #[cfg_attr(feature = "async-std", async_std::test)] async fn callbacks_partials_and_case_folding_keep_their_existing_order() { - let reservation = TcpListener::bind("127.0.0.1:0").unwrap(); - let address = reservation.local_addr().unwrap(); let events: Events = Arc::default(); let mut router = Router::new(); - router.set_listener_address("127.0.0.1"); - router.set_port(address.port()); - router.set_ssl_acceptor(acceptor()); router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); router.attach(Observer(events.clone())); @@ -162,18 +202,7 @@ async fn callbacks_partials_and_case_folding_keep_their_existing_order() { response.content.push('!'); }, ); - drop(reservation); - - #[cfg(feature = "tokio")] - let server = tokio::spawn(async move { router.run().await.unwrap() }); - #[cfg(feature = "async-std")] - let server = - async_std::task::spawn(async move { router.run().await.unwrap() }); - #[cfg(feature = "tokio")] - let run_client = tokio::task::spawn_blocking; - #[cfg(feature = "async-std")] - let run_client = async_std::task::spawn_blocking; - let client = run_client(move || { + serve_requests(router, move |address| { request( address, "/Users/Alice", @@ -184,18 +213,8 @@ async fn callbacks_partials_and_case_folding_keep_their_existing_order() { "/binary", b"20 application/octet-stream\r\n\x00\xff", ); - }); - - #[cfg(feature = "tokio")] - client.await.unwrap(); - - #[cfg(feature = "async-std")] - client.await; - - #[cfg(feature = "tokio")] - server.abort(); - #[cfg(feature = "async-std")] - server.cancel().await; + }) + .await; assert_eq!( *events.lock().unwrap(), [ @@ -217,3 +236,50 @@ async fn callbacks_partials_and_case_folding_keep_their_existing_order() { ] ); } + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn request_framing_handles_split_characters_and_terminates_errors() { + let mut router = Router::new(); + + router.mount("/caf%C3%A9", |_| Response::success("accepted")); + router.set_error_handler(|_| Response::success("accepted")); + serve_requests(router, |address| { + let success = b"20 text/gemini; charset=utf-8; lang=en\r\naccepted\n"; + let invalid_utf8 = b"gemini://localhost/\xff"; + let invalid_url = "not a url"; + let utf8_error = format!( + "59 The server (Windmark) received a bad request: {}\r\n", + std::str::from_utf8(invalid_utf8.as_slice()).unwrap_err() + ); + let url_error = format!( + "59 The server (Windmark) received a bad request: {}\r\n", + url::Url::parse(invalid_url).unwrap_err() + ); + let prefix = "gemini://localhost/"; + let maximum_url = format!("{prefix}{}", "a".repeat(1022 - prefix.len())); + + assert_eq!(maximum_url.len(), 1022); + request_chunks( + address, + &[b"gemini://localhost/caf\xc3", b"\xa9\r", b"\n"], + success, + ); + request_chunks(address, &[invalid_utf8, b"\r\n"], utf8_error.as_bytes()); + request_chunks( + address, + &[invalid_url.as_bytes(), b"\r\n"], + url_error.as_bytes(), + ); + request_chunks(address, &[maximum_url.as_bytes(), b"\r\n"], success); + request_chunks( + address, + &[maximum_url.as_bytes(), b"a\r\n"], + b"59 The server (Windmark) received a request exceeding 1024 bytes\r\n", + ); + }) + .await; +} From 0f82c4220836e44751d688eacc685a4842f38567 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Fri, 11 Sep 2026 07:33:01 +0000 Subject: [PATCH 16/40] refactor(router)!: Make async module attachment awaitable Avoid entering and blocking a runtime from synchronous router configuration. --- Cargo.toml | 3 ++ examples/async_stateful_module.rs | 2 +- src/router.rs | 26 ++++------------- tests/consumer.rs | 2 +- tests/consumer/src/bin/valid.rs | 7 +++-- tests/module.rs | 46 +++++++++++++++++++++++++++++++ tests/router_runtime.rs | 36 +++++++++++++++++++++++- 7 files changed, 97 insertions(+), 25 deletions(-) create mode 100644 tests/module.rs diff --git a/Cargo.toml b/Cargo.toml index d935fb7..a3a31aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,3 +126,6 @@ name = "consumer" [[test]] name = "router_runtime" + +[[test]] +name = "module" diff --git a/examples/async_stateful_module.rs b/examples/async_stateful_module.rs index 51e9cb1..30225d0 100644 --- a/examples/async_stateful_module.rs +++ b/examples/async_stateful_module.rs @@ -45,7 +45,7 @@ async fn main() -> Result<(), Box> { router.enable_default_logger(true); } - router.attach_async(Clicker::default()); + router.attach_async(Clicker::default()).await; router.mount("/", windmark::success!("Hello!")); router.run().await } diff --git a/src/router.rs b/src/router.rs index f3106d7..8f67a8f 100644 --- a/src/router.rs +++ b/src/router.rs @@ -34,17 +34,6 @@ use tokio::{ }; use url::Url; -macro_rules! block { - ($body:expr) => { - #[cfg(feature = "tokio")] - ::tokio::task::block_in_place(|| { - ::tokio::runtime::Handle::current().block_on(async { $body }); - }); - #[cfg(feature = "async-std")] - ::async_std::task::block_on(async { $body }); - }; -} - macro_rules! or_error { ($stream:ident, $operation:expr, $error_format:literal) => { match $operation { @@ -911,8 +900,8 @@ impl Router { self } - /// Attach a stateful module, completing its asynchronous initialisation - /// before returning. Tokio requires an active multi-threaded runtime. + /// Attach a stateful module and await its initialisation before registering + /// its request hooks. /// /// Like a stateless module, a stateful module extends a `Router` and gets /// full access to it during attachment. Third parties can provide modules @@ -961,18 +950,15 @@ impl Router { /// /// # #[windmark::main] /// # async fn main() { - /// Router::new().attach_async(Clicker::default()); + /// Router::new().attach_async(Clicker::default()).await; /// # } /// ``` - pub fn attach_async( + pub async fn attach_async( &mut self, mut module: impl AsyncModule + 'static, ) -> &mut Self { - block!({ - module.on_attach(self).await; - - (*self.async_modules.lock().await).push(Box::new(module)); - }); + module.on_attach(self).await; + (*self.async_modules.lock().await).push(Box::new(module)); self } diff --git a/tests/consumer.rs b/tests/consumer.rs index 9acdce2..81c7204 100644 --- a/tests/consumer.rs +++ b/tests/consumer.rs @@ -1,7 +1,7 @@ use std::{path::PathBuf, process::Command}; #[test] -fn public_api_and_existing_macro_behavior_are_preserved() { +fn consumer_api_and_macro_forms_match_expected_behavior() { let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/consumer/Cargo.toml"); let target = std::env::var_os("CARGO_TARGET_DIR") diff --git a/tests/consumer/src/bin/valid.rs b/tests/consumer/src/bin/valid.rs index 8bc30da..030c56f 100644 --- a/tests/consumer/src/bin/valid.rs +++ b/tests/consumer/src/bin/valid.rs @@ -106,12 +106,15 @@ fn context() -> RouteContext { } } +async fn attach_module(router: &mut Router) { + router.attach_async(AwaitableModule).await; +} + fn main() { let mut router = Router::new(); let capsule = Capsule::new(); let _empty = Empty::new(); - let _attachment: fn(&mut Router, AwaitableModule) -> &mut Router = - Router::attach_async; + let _attachment = attach_module; let _route: &dyn RouteResponse = &Replies; let _error: &dyn ErrorResponse = &Replies; diff --git a/tests/module.rs b/tests/module.rs new file mode 100644 index 0000000..4101137 --- /dev/null +++ b/tests/module.rs @@ -0,0 +1,46 @@ +use std::{ + future::Future, + pin::pin, + sync::{Arc, Mutex}, + task::Poll, +}; +use windmark::{module::AsyncModule, router::Router}; + +struct Initializer(Arc>>); + +#[async_trait::async_trait] +impl AsyncModule for Initializer { + async fn on_attach(&mut self, router: &mut Router) { + self.0.lock().unwrap().push("started"); + #[cfg(feature = "tokio")] + tokio::task::yield_now().await; + #[cfg(feature = "async-std")] + async_std::task::yield_now().await; + router.set_port(0); + self.0.lock().unwrap().push("finished"); + } +} + +#[cfg_attr(feature = "tokio", tokio::test(flavor = "current_thread"))] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn attachment_awaits_initialization_and_returns_the_router() { + let events = Arc::new(Mutex::new(Vec::new())); + let mut router = Router::new(); + let address = std::ptr::from_ref(&router); + let mut attachment = pin!(router.attach_async(Initializer(events.clone()))); + + assert!(events.lock().unwrap().is_empty()); + std::future::poll_fn(|context| { + assert!(attachment.as_mut().poll(context).is_pending()); + + Poll::Ready(()) + }) + .await; + assert_eq!(*events.lock().unwrap(), ["started"]); + + let attached = attachment.await; + + assert_eq!(*events.lock().unwrap(), ["started", "finished"]); + assert_eq!(std::ptr::from_ref(attached), address); + attached.set_port(1965); +} diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs index 9ea1bc5..0e832ed 100644 --- a/tests/router_runtime.rs +++ b/tests/router_runtime.rs @@ -15,7 +15,7 @@ use std::{ }; use windmark::{ context::{HookContext, RouteContext}, - module::Module, + module::{AsyncModule, Module}, response::Response, router::Router, router_option::RouterOption, @@ -39,6 +39,29 @@ impl Module for Observer { } } +struct AsyncObserver { + events: Events, + initialized: bool, +} + +#[async_trait::async_trait] +impl AsyncModule for AsyncObserver { + async fn on_attach(&mut self, _: &mut Router) { + self.initialized = true; + + self.events.lock().unwrap().push("async-attach"); + } + + async fn on_pre_route(&mut self, _: &HookContext) { + assert!(self.initialized); + self.events.lock().unwrap().push("async-pre"); + } + + async fn on_post_route(&mut self, _: &HookContext) { + self.events.lock().unwrap().push("async-post"); + } +} + fn acceptor() -> SslAcceptor { let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap(); let key = PKey::from_ec_key(EcKey::generate(&group).unwrap()).unwrap(); @@ -159,6 +182,12 @@ async fn callbacks_partials_and_case_folding_keep_their_existing_order() { router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); router.attach(Observer(events.clone())); + router + .attach_async(AsyncObserver { + events: events.clone(), + initialized: false, + }) + .await; let recorded = events.clone(); @@ -219,18 +248,23 @@ async fn callbacks_partials_and_case_folding_keep_their_existing_order() { *events.lock().unwrap(), [ "attach", + "async-attach", + "async-pre", "module-pre", "callback-pre", "header", "footer", "route", + "async-post", "module-post", "callback-post", + "async-pre", "module-pre", "callback-pre", "header", "footer", "route", + "async-post", "module-post", "callback-post", ] From a191137d30dfead6710e08b1fba1dd2a3af30129 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Fri, 11 Sep 2026 08:17:27 +0000 Subject: [PATCH 17/40] fix(router)!: Preserve parameters in case-insensitive routing BREAKING CHANGE: Conflicting routes now cause a panic when mounting routes or enabling case-insensitive matching. --- src/context/parameters.rs | 20 +++++ src/router.rs | 78 +++++++++++-------- src/router/routes.rs | 97 ++++++++++++++++++++++++ src/router_option.rs | 6 +- tests/router.rs | 153 ++++++++++++++++++++++++++++++++++++-- tests/router_runtime.rs | 24 ++++-- 6 files changed, 333 insertions(+), 45 deletions(-) create mode 100644 src/router/routes.rs diff --git a/src/context/parameters.rs b/src/context/parameters.rs index 413a241..8a5cd4d 100644 --- a/src/context/parameters.rs +++ b/src/context/parameters.rs @@ -11,6 +11,26 @@ impl Parameters { ) } + pub(crate) fn from_lookup( + parameters: &matchit::Params<'_, '_>, + lookup: &str, + original: &str, + ) -> Self { + Self( + parameters + .iter() + .map(|(name, value)| { + // Matchit borrows each value from the lookup path. ASCII case folding + // preserves byte offsets, so the original path has the same spans. + let start = value.as_ptr().addr() - lookup.as_ptr().addr(); + let end = start + value.len(); + + (name.to_owned(), original[start..end].to_owned()) + }) + .collect(), + ) + } + #[must_use] pub fn get(&self, key: &str) -> Option<&str> { self diff --git a/src/router.rs b/src/router.rs index 8f67a8f..6ac66e3 100644 --- a/src/router.rs +++ b/src/router.rs @@ -2,13 +2,7 @@ use crate::{ context::{ErrorContext, HookContext, RouteContext}, - handler::{ - ErrorResponse, - Partial, - PostRouteHook, - PreRouteHook, - RouteResponse, - }, + handler::{ErrorResponse, Partial, PostRouteHook, PreRouteHook}, module::{AsyncModule, Module}, response::Response, router_option::RouterOption, @@ -34,6 +28,10 @@ use tokio::{ }; use url::Url; +mod routes; + +use routes::Routes; + macro_rules! or_error { ($stream:ident, $operation:expr, $error_format:literal) => { match $operation { @@ -62,7 +60,7 @@ type Stream = async_std_openssl::SslStream; /// A router dispatches requests through its handlers, partials, and modules. #[derive(Clone)] pub struct Router { - routes: matchit::Router>, + routes: Routes, error_handler: Arc, private_key_file_name: String, private_key_content: Option, @@ -88,7 +86,7 @@ pub struct Router { } struct RequestHandler { - routes: matchit::Router>, + routes: Routes, error_handler: Arc, headers: Arc<[Box]>, footers: Arc<[Box]>, @@ -154,16 +152,16 @@ impl RequestHandler { let route_path = resolve_lookup_path(&self.options, url.path(), |candidate| { - self.routes.at(candidate).is_ok() + self.routes.contains(candidate) }); let route = self.routes.at(&route_path); let peer_certificate = stream.ssl().peer_certificate(); - let hook_context = HookContext::new( - stream.get_ref().peer_addr(), - url.clone(), - route.as_ref().ok().map(|route| route.params.clone()), - peer_certificate.clone(), - ); + let hook_context = HookContext { + peer_address: stream.get_ref().peer_addr().ok(), + url: url.clone(), + parameters: route.as_ref().ok().map(|route| route.parameters.clone()), + certificate: peer_certificate.clone(), + }; for module in &mut *self.async_modules.lock().await { module.on_pre_route(&hook_context).await; @@ -178,12 +176,12 @@ impl RequestHandler { self.pre_route_callback.call(&hook_context); let mut content = if let Ok(ref route) = route { - let route_context = RouteContext::new( - stream.get_ref().peer_addr(), + let route_context = RouteContext { + peer_address: stream.get_ref().peer_addr().ok(), url, - &route.params, - peer_certificate, - ); + parameters: route.parameters.clone(), + certificate: peer_certificate, + }; for partial_header in self.headers.iter() { writeln!(&mut header, "{}", partial_header.call(&route_context)) @@ -352,11 +350,7 @@ fn resolve_lookup_path( request_path: &str, route_exists: impl Fn(&str) -> bool, ) -> String { - let path = if options.contains(&RouterOption::AllowCaseInsensitiveLookup) { - request_path.to_lowercase() - } else { - request_path.to_owned() - }; + let path = request_path.to_owned(); if route_exists(&path) { return path; @@ -481,7 +475,8 @@ impl Router { /// /// # Panics /// - /// May panic if the route cannot be mounted. + /// This method panics if the route is invalid or conflicts with a mounted + /// route, including conflicts under case-insensitive matching when enabled. pub fn mount( &mut self, route: impl Into + AsRef, @@ -1081,6 +1076,12 @@ impl Router { /// Add optional features to the router. /// + /// # Panics + /// + /// This method panics if enabling case-insensitive matching would make + /// existing routes conflict. Case-insensitive matching remains disabled + /// when validation fails. + /// /// # Examples /// /// ```rust @@ -1091,6 +1092,13 @@ impl Router { /// ``` pub fn add_options(&mut self, options: &[RouterOption]) -> &mut Self { for option in options { + if *option == RouterOption::AllowCaseInsensitiveLookup { + self + .routes + .enable_case_insensitive() + .expect("routes conflict under case-insensitive matching"); + } + self.options.insert(*option); } @@ -1099,6 +1107,12 @@ impl Router { /// Toggle optional features for the router. /// + /// # Panics + /// + /// This method panics if enabling case-insensitive matching would make + /// existing routes conflict. Case-insensitive matching remains disabled + /// when validation fails. + /// /// # Examples /// /// ```rust @@ -1110,9 +1124,9 @@ impl Router { pub fn toggle_options(&mut self, options: &[RouterOption]) -> &mut Self { for option in options { if self.options.contains(option) { - self.options.remove(option); + self.remove_options(std::slice::from_ref(option)); } else { - self.options.insert(*option); + self.add_options(std::slice::from_ref(option)); } } @@ -1131,6 +1145,10 @@ impl Router { /// ``` pub fn remove_options(&mut self, options: &[RouterOption]) -> &mut Self { for option in options { + if *option == RouterOption::AllowCaseInsensitiveLookup { + self.routes.disable_case_insensitive(); + } + self.options.remove(option); } @@ -1159,7 +1177,7 @@ impl Router { impl Default for Router { fn default() -> Self { Self { - routes: matchit::Router::new(), + routes: Routes::default(), error_handler: Arc::new(|_| { async { Response::not_found( diff --git a/src/router/routes.rs b/src/router/routes.rs new file mode 100644 index 0000000..0fc1e78 --- /dev/null +++ b/src/router/routes.rs @@ -0,0 +1,97 @@ +use crate::{context::Parameters, handler::RouteResponse}; +use std::sync::Arc; + +#[derive(Clone, Default)] +pub(super) struct Routes { + exact: matchit::Router>, + folded: Option>>, + registrations: Vec<(String, Arc)>, +} + +pub(super) struct MatchedRoute<'router> { + pub value: &'router Arc, + pub parameters: Parameters, +} + +impl Routes { + pub fn insert( + &mut self, + path: String, + handler: Arc, + ) -> Result<(), matchit::InsertError> { + let mut folded = self.folded.clone(); + + if let Some(routes) = &mut folded { + routes.insert(fold_pattern(&path), handler.clone())?; + } + + self.exact.insert(path.clone(), handler.clone())?; + self.registrations.push((path, handler)); + + self.folded = folded; + + Ok(()) + } + + pub fn enable_case_insensitive( + &mut self, + ) -> Result<(), matchit::InsertError> { + if self.folded.is_some() { + return Ok(()); + } + + let mut routes = matchit::Router::new(); + + for (path, handler) in &self.registrations { + routes.insert(fold_pattern(path), handler.clone())?; + } + + self.folded = Some(routes); + + Ok(()) + } + + pub fn disable_case_insensitive(&mut self) { self.folded = None; } + + pub fn contains(&self, path: &str) -> bool { + self.folded.as_ref().map_or_else( + || self.exact.at(path).is_ok(), + |routes| routes.at(&path.to_ascii_lowercase()).is_ok(), + ) + } + + pub fn at( + &self, + path: &str, + ) -> Result, matchit::MatchError> { + if let Some(routes) = &self.folded { + let lookup = path.to_ascii_lowercase(); + let matched = routes.at(&lookup)?; + + Ok(MatchedRoute { + value: matched.value, + parameters: Parameters::from_lookup(&matched.params, &lookup, path), + }) + } else { + let matched = self.exact.at(path)?; + + Ok(MatchedRoute { + value: matched.value, + parameters: Parameters::from_parameters(&matched.params), + }) + } + } +} + +fn fold_pattern(path: &str) -> String { + let mut folded = String::with_capacity(path.len()); + + for segment in path.split_inclusive('/') { + let parameter = segment.find([':', '*']).unwrap_or(segment.len()); + + folded.push_str(&segment[..parameter].to_ascii_lowercase()); + folded.push_str(&segment[parameter..]); + } + + folded +} diff --git a/src/router_option.rs b/src/router_option.rs index d555763..68740b5 100644 --- a/src/router_option.rs +++ b/src/router_option.rs @@ -7,7 +7,9 @@ pub enum RouterOption { /// If enabled, adds a trailing slash to the request URL path if a route /// exists for the path with the slash (e.g., `/foo` becomes `/foo/`). AddMissingTrailingSlash, - /// If enabled, the router will perform case-insensitive matching for - /// incoming request URL paths (e.g., `/foo` will match `/Foo` or `/FOO`). + /// If enabled, the router ignores ASCII case in static route segments. + /// Parameter names and captured values retain their original spelling. + /// Routes that conflict under this matching rule cannot be registered + /// together while the option is enabled. AllowCaseInsensitiveLookup, } diff --git a/tests/router.rs b/tests/router.rs index 3a299d2..ecdcb69 100644 --- a/tests/router.rs +++ b/tests/router.rs @@ -18,7 +18,14 @@ fn resolve( let option_set = options.iter().copied().collect::>(); resolve_lookup_path(&option_set, request_path, |candidate| { - matcher.at(candidate).is_ok() + let lookup = if options.contains(&RouterOption::AllowCaseInsensitiveLookup) + { + candidate.to_ascii_lowercase() + } else { + candidate.to_owned() + }; + + matcher.at(&lookup).is_ok() }) } @@ -38,14 +45,14 @@ fn dynamic_route_is_matched_without_modification() { } #[test] -fn case_insensitive_lookup_lowercases_the_path() { +fn case_insensitive_lookup_preserves_the_path() { assert_eq!( resolve( &[RouterOption::AllowCaseInsensitiveLookup], "/FoO", &["/foo"] ), - "/foo", + "/FoO", ); } @@ -112,7 +119,7 @@ fn case_insensitive_lookup_combines_with_trailing_slash_removal() { "/Foo/", &["/foo"], ), - "/foo", + "/Foo", ); } @@ -221,7 +228,7 @@ fn footer_rendering_preserves_empty_values_and_callback_order() { } #[test] -fn case_folding_preserves_existing_unicode_and_probe_order() { +fn path_resolution_preserves_unicode_and_probe_order() { let options = [ RouterOption::AllowCaseInsensitiveLookup, RouterOption::AddMissingTrailingSlash, @@ -232,11 +239,11 @@ fn case_folding_preserves_existing_unicode_and_probe_order() { let path = resolve_lookup_path(&options, "/Users/ÄLICE", |candidate| { probes.borrow_mut().push(candidate.to_owned()); - candidate == "/users/älice/" + candidate == "/Users/ÄLICE/" }); - assert_eq!(path, "/users/älice/"); - assert_eq!(*probes.borrow(), ["/users/älice", "/users/älice/"]); + assert_eq!(path, "/Users/ÄLICE/"); + assert_eq!(*probes.borrow(), ["/Users/ÄLICE", "/Users/ÄLICE/"]); } #[test] @@ -285,3 +292,133 @@ fn response_assembly_preserves_publicly_constructible_states() { ); } } + +#[test] +fn case_insensitive_routes_preserve_parameter_names_and_values() { + let mut router = super::Router::new(); + + router.mount("/Users/:Name/Files/*FilePath", |_| { + Response::success("file") + }); + router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); + + let matched = router + .routes + .at("/uSeRs/ÄLICE/fIlEs/Photo%2FOne.PNG") + .unwrap(); + + assert_eq!(matched.parameters.get("Name"), Some("ÄLICE")); + assert_eq!(matched.parameters.get("FilePath"), Some("Photo%2FOne.PNG")); + assert_eq!(matched.parameters.get("name"), None); +} + +#[test] +fn case_insensitive_routes_preserve_static_priority_and_inline_parameters() { + let mut router = super::Router::new(); + + router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); + router.mount("/Users/:Name", |_| Response::success("user")); + router.mount("/Users/Me", |_| Response::success("profile")); + router.mount("/Files/Prefix:Name", |_| Response::success("file")); + + let profile = router.routes.at("/users/ME").unwrap(); + let file = router.routes.at("/FILES/prefixREPORT.PDF").unwrap(); + + assert!(profile.parameters.is_empty()); + assert_eq!(file.parameters.get("Name"), Some("REPORT.PDF")); +} + +#[test] +fn case_insensitive_routes_restore_repeated_values_from_their_own_positions() { + let mut router = super::Router::new(); + + router.mount("/Same/:First/:Second", |_| Response::success("pair")); + router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); + + let matched = router.routes.at("/same/SAME/SaMe").unwrap(); + + assert_eq!(matched.parameters.get("First"), Some("SAME")); + assert_eq!(matched.parameters.get("Second"), Some("SaMe")); +} + +#[test] +fn case_insensitive_matching_can_be_disabled_reenabled_and_cloned() { + let mut router = super::Router::new(); + + router.mount("/Mixed/:Name", |_| Response::success("mixed")); + assert!(!router.routes.contains("/mixed/Alice")); + router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); + assert!(router.routes.contains("/mixed/Alice")); + + let cloned = router.clone(); + + router.toggle_options(&[RouterOption::AllowCaseInsensitiveLookup]); + assert!(!router.routes.contains("/mixed/Alice")); + assert!(cloned.routes.contains("/mixed/Alice")); + router.toggle_options(&[RouterOption::AllowCaseInsensitiveLookup]); + assert!(router.routes.contains("/mixed/Alice")); + router.remove_options(&[RouterOption::AllowCaseInsensitiveLookup]); + assert!(!router.routes.contains("/mixed/Alice")); + assert!(router.routes.contains("/Mixed/Alice")); +} + +#[test] +fn enabling_case_insensitive_matching_rejects_existing_conflicts() { + let mut router = super::Router::new(); + + router.mount("/Foo", |_| Response::success("upper")); + router.mount("/foo", |_| Response::success("lower")); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); + })); + + assert!(result.is_err()); + assert!(!router + .options + .contains(&RouterOption::AllowCaseInsensitiveLookup)); + assert!(router.routes.contains("/Foo")); + assert!(router.routes.contains("/foo")); + assert!(!router.routes.contains("/FOO")); +} + +#[test] +fn mounting_case_conflicts_preserves_existing_routes() { + let mut router = super::Router::new(); + + router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); + router.mount("/Users/:Name", |_| Response::success("user")); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + router.mount("/users/:Other", |_| Response::success("conflict")); + })); + + assert!(result.is_err()); + + let matched = router.routes.at("/USERS/Alice").unwrap(); + + assert_eq!(matched.parameters.get("Name"), Some("Alice")); + assert_eq!(matched.parameters.get("Other"), None); + router.mount("/Other", |_| Response::success("other")); + assert!(router.routes.contains("/OTHER")); +} + +#[test] +fn trailing_slash_fixes_preserve_case_insensitive_parameters() { + let mut router = super::Router::new(); + + router.mount("/Users/:Name/", |_| Response::success("user")); + router.add_options(&[ + RouterOption::AllowCaseInsensitiveLookup, + RouterOption::AddMissingTrailingSlash, + ]); + + let path = + resolve_lookup_path(&router.options, "/uSeRs/Alice", |candidate| { + router.routes.contains(candidate) + }); + let matched = router.routes.at(&path).unwrap(); + + assert_eq!(path, "/uSeRs/Alice/"); + assert_eq!(matched.parameters.get("Name"), Some("Alice")); +} diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs index 0e832ed..fd29375 100644 --- a/tests/router_runtime.rs +++ b/tests/router_runtime.rs @@ -30,11 +30,25 @@ impl Module for Observer { self.0.lock().unwrap().push("attach"); } - fn on_pre_route(&mut self, _: &HookContext) { + fn on_pre_route(&mut self, context: &HookContext) { + if context.url.path() == "/Users/Alice" { + assert_eq!( + context.parameters.as_ref().unwrap().get("Name"), + Some("Alice") + ); + } + self.0.lock().unwrap().push("module-pre"); } - fn on_post_route(&mut self, _: &HookContext) { + fn on_post_route(&mut self, context: &HookContext) { + if context.url.path() == "/Users/Alice" { + assert_eq!( + context.parameters.as_ref().unwrap().get("Name"), + Some("Alice") + ); + } + self.0.lock().unwrap().push("module-post"); } } @@ -211,9 +225,9 @@ async fn callbacks_partials_and_case_folding_keep_their_existing_order() { let recorded = events.clone(); - router.mount("/users/:name", move |context: RouteContext| { + router.mount("/Users/:Name", move |context: RouteContext| { recorded.lock().unwrap().push("route"); - Response::success(context.parameters.get("name").unwrap()) + Response::success(context.parameters.get("Name").unwrap()) }); let recorded = events.clone(); @@ -235,7 +249,7 @@ async fn callbacks_partials_and_case_folding_keep_their_existing_order() { request( address, "/Users/Alice", - b"20 text/gemini; charset=utf-8; lang=en\r\nHEADER\nalice!\nFOOTER", + b"20 text/gemini; charset=utf-8; lang=en\r\nHEADER\nAlice!\nFOOTER", ); request( address, From 9ce6984fc08e8634f7aab8c5fa23916a6173025c Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 05:15:24 +0000 Subject: [PATCH 18/40] fix(router): Retain registered partials when starting a server Snapshot shared partial handlers instead of draining their registrations, so starting one router does not remove partials from its clones or later runs. --- src/router.rs | 20 +++++++++----------- tests/router.rs | 6 +++--- tests/router_runtime.rs | 42 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/src/router.rs b/src/router.rs index 6ac66e3..a298dbc 100644 --- a/src/router.rs +++ b/src/router.rs @@ -66,8 +66,8 @@ pub struct Router { private_key_content: Option, certificate_file_name: String, certificate_content: Option, - headers: Arc>>>, - footers: Arc>>>, + headers: Arc>>>, + footers: Arc>>>, ssl_acceptor: Arc, manual_acceptor_set: bool, #[cfg(feature = "logger")] @@ -88,8 +88,8 @@ pub struct Router { struct RequestHandler { routes: Routes, error_handler: Arc, - headers: Arc<[Box]>, - footers: Arc<[Box]>, + headers: Arc<[Arc]>, + footers: Arc<[Arc]>, pre_route_callback: Arc, post_route_callback: Arc, character_set: String, @@ -246,7 +246,7 @@ fn serialize_response( } fn render_footer( - partials: &[Box], + partials: &[Arc], context: &RouteContext, ) -> String { let mut footer = String::new(); @@ -538,7 +538,7 @@ impl Router { /// ``` pub fn add_header(&mut self, handler: impl Partial + 'static) -> &mut Self { (*self.headers.lock().expect("headers lock poisoned")) - .push(Box::new(handler)); + .push(Arc::new(handler)); self } @@ -560,7 +560,7 @@ impl Router { /// ``` pub fn add_footer(&mut self, handler: impl Partial + 'static) -> &mut Self { (*self.footers.lock().expect("footers lock poisoned")) - .push(Box::new(handler)); + .push(Arc::new(handler)); self } @@ -620,15 +620,13 @@ impl Router { .headers .lock() .expect("headers lock poisoned") - .drain(..) - .collect::>() + .clone() .into(), footers: self .footers .lock() .expect("footers lock poisoned") - .drain(..) - .collect::>() + .clone() .into(), pre_route_callback: self.pre_route_callback.clone(), post_route_callback: self.post_route_callback.clone(), diff --git a/tests/router.rs b/tests/router.rs index ecdcb69..01bcd77 100644 --- a/tests/router.rs +++ b/tests/router.rs @@ -204,18 +204,18 @@ fn footer_rendering_preserves_empty_values_and_callback_order() { for values in [vec![], vec![""], vec!["", ""], vec!["a", "", "b\n"]] { let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let partials: Vec> = values + let partials: Vec> = values .iter() .enumerate() .map(|(index, value)| { let calls = calls.clone(); let value = (*value).to_owned(); - Box::new(move |context: &crate::context::RouteContext| { + std::sync::Arc::new(move |context: &crate::context::RouteContext| { assert_eq!(context.url.path(), "/footer"); calls.lock().unwrap().push(index); value.clone() - }) as Box + }) as std::sync::Arc }) .collect(); diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs index fd29375..97f43fa 100644 --- a/tests/router_runtime.rs +++ b/tests/router_runtime.rs @@ -331,3 +331,45 @@ async fn request_framing_handles_split_characters_and_terminates_errors() { }) .await; } + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn cloned_routers_retain_partials_and_running_servers_keep_their_snapshot( +) { + let mut router = Router::new(); + + router.mount("/", |_| Response::success("BODY")); + router.add_header(|_: &windmark::context::RouteContext| "HEADER".to_owned()); + router.add_footer(|_: &windmark::context::RouteContext| "FOOTER".to_owned()); + + let mut registration = router.clone(); + + serve_requests(router.clone(), move |address| { + let expected = + b"20 text/gemini; charset=utf-8; lang=en\r\nHEADER\nBODY\nFOOTER"; + + request(address, "/", expected); + registration.add_header(|_: &windmark::context::RouteContext| { + "SECOND HEADER".to_owned() + }); + registration.add_footer(|_: &windmark::context::RouteContext| { + "SECOND FOOTER".to_owned() + }); + request(address, "/", expected); + }) + .await; + + for configured in [router.clone(), router] { + serve_requests(configured, |address| { + request( + address, + "/", + b"20 text/gemini; charset=utf-8; lang=en\r\nHEADER\nSECOND HEADER\nBODY\nFOOTER\nSECOND FOOTER", + ); + }) + .await; + } +} From f4999f953d8892a94eecc008189da887e1e381de Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 07:28:07 +0000 Subject: [PATCH 19/40] fix(router)!: Lock modules individually during request hooks BREAKING CHANGE: Different modules may execute concurrently across requests. A poisoned synchronous module no longer disables other modules. --- src/module.rs | 8 ++ src/router.rs | 58 +++++---- tests/router.rs | 81 +++++++++++++ tests/router_runtime.rs | 257 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 380 insertions(+), 24 deletions(-) diff --git a/src/module.rs b/src/module.rs index 612e621..3ee7881 100644 --- a/src/module.rs +++ b/src/module.rs @@ -1,5 +1,10 @@ use crate::context::HookContext; +/// Each module runs one hook at a time. Different modules may run concurrently +/// across requests, while each request visits modules in registration order. +/// +/// If a hook panics, subsequent requests skip that module because its lock is +/// poisoned; other modules remain available. pub trait Module { /// The router calls this hook during attachment, before registering /// request hooks. @@ -14,6 +19,9 @@ pub trait Module { fn on_post_route(&mut self, _: &HookContext) {} } +/// Each module runs one hook at a time, including across awaits. Different +/// modules may run concurrently across requests, while each request visits +/// modules in registration order. #[async_trait::async_trait] pub trait AsyncModule: Send + Sync { /// The router calls this hook during attachment, before registering diff --git a/src/router.rs b/src/router.rs index a298dbc..f691565 100644 --- a/src/router.rs +++ b/src/router.rs @@ -56,6 +56,8 @@ type TcpStream = async_std::net::TcpStream; type Stream = tokio_openssl::SslStream; #[cfg(feature = "async-std")] type Stream = async_std_openssl::SslStream; +type SharedModule = Arc>>; +type SharedAsyncModule = Arc>>; /// A router dispatches requests through its handlers, partials, and modules. #[derive(Clone)] @@ -79,8 +81,8 @@ pub struct Router { character_set: String, languages: Vec, port: u16, - async_modules: Arc>>>, - modules: Arc>>>, + async_modules: Arc>>, + modules: Arc>>, options: HashSet, listener_address: String, } @@ -94,8 +96,8 @@ struct RequestHandler { post_route_callback: Arc, character_set: String, languages_joined: String, - async_modules: Arc>>>, - modules: Arc>>>, + async_modules: Arc>>, + modules: Arc>>, options: HashSet, } @@ -162,17 +164,15 @@ impl RequestHandler { parameters: route.as_ref().ok().map(|route| route.parameters.clone()), certificate: peer_certificate.clone(), }; + let async_modules = self.async_modules.lock().await.clone(); - for module in &mut *self.async_modules.lock().await { - module.on_pre_route(&hook_context).await; - } - - if let Ok(mut modules) = self.modules.lock() { - for module in &mut *modules { - module.on_pre_route(&hook_context); - } + for module in async_modules { + module.lock().await.on_pre_route(&hook_context).await; } + call_modules(&self.modules, |module| { + module.on_pre_route(&hook_context); + }); self.pre_route_callback.call(&hook_context); let mut content = if let Ok(ref route) = route { @@ -201,17 +201,15 @@ impl RequestHandler { )) .await }; + let async_modules = self.async_modules.lock().await.clone(); - for module in &mut *self.async_modules.lock().await { - module.on_post_route(&hook_context).await; - } - - if let Ok(mut modules) = self.modules.lock() { - for module in &mut *modules { - module.on_post_route(&hook_context); - } + for module in async_modules { + module.lock().await.on_post_route(&hook_context).await; } + call_modules(&self.modules, |module| { + module.on_post_route(&hook_context); + }); self.post_route_callback.call(&hook_context, &mut content); let status_line = @@ -227,6 +225,21 @@ impl RequestHandler { } } +fn call_modules( + modules: &Mutex>, + hook: impl Fn(&mut dyn Module), +) { + let modules = modules.lock().ok().map(|modules| modules.clone()); + + if let Some(modules) = modules { + for module in modules { + if let Ok(mut module) = module.lock() { + hook(module.as_mut()); + } + } + } +} + fn serialize_response( content: Response, status_line: &str, @@ -951,7 +964,8 @@ impl Router { mut module: impl AsyncModule + 'static, ) -> &mut Self { module.on_attach(self).await; - (*self.async_modules.lock().await).push(Box::new(module)); + (*self.async_modules.lock().await) + .push(Arc::new(AsyncMutex::new(Box::new(module)))); self } @@ -1010,7 +1024,7 @@ impl Router { ) -> &mut Self { module.on_attach(self); (*self.modules.lock().expect("modules lock poisoned")) - .push(Box::new(module)); + .push(Arc::new(Mutex::new(Box::new(module)))); self } diff --git a/tests/router.rs b/tests/router.rs index 01bcd77..e5154f9 100644 --- a/tests/router.rs +++ b/tests/router.rs @@ -422,3 +422,84 @@ fn trailing_slash_fixes_preserve_case_insensitive_parameters() { assert_eq!(path, "/uSeRs/Alice/"); assert_eq!(matched.parameters.get("Name"), Some("Alice")); } + +#[test] +fn synchronous_module_locks_allow_other_modules_to_progress() { + struct SignalingModule(std::sync::mpsc::Sender<()>); + + impl crate::module::Module for SignalingModule { + fn on_pre_route(&mut self, _: &crate::context::HookContext) { + self.0.send(()).unwrap(); + } + } + + struct BlockingModule { + entered: std::sync::mpsc::Sender<()>, + release: std::sync::mpsc::Receiver<()>, + blocked: bool, + } + + impl crate::module::Module for BlockingModule { + fn on_pre_route(&mut self, _: &crate::context::HookContext) { + if !self.blocked { + self.blocked = true; + + self.entered.send(()).unwrap(); + self + .release + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + } + } + } + + let (signal, progress) = std::sync::mpsc::channel(); + let (entered, entry) = std::sync::mpsc::channel(); + let (release, resume) = std::sync::mpsc::channel(); + let modules: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(vec![ + std::sync::Arc::new(std::sync::Mutex::new(Box::new(SignalingModule( + signal, + )))), + std::sync::Arc::new(std::sync::Mutex::new(Box::new(BlockingModule { + entered, + release: resume, + blocked: false, + }))), + ])); + let first_modules = modules.clone(); + let first = std::thread::spawn(move || { + super::call_modules(&first_modules, |module| { + module.on_pre_route(&crate::context::HookContext { + peer_address: None, + url: url::Url::parse("gemini://localhost/").unwrap(), + parameters: None, + certificate: None, + }); + }); + }); + + progress + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + entry + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + + let second = std::thread::spawn(move || { + super::call_modules(&modules, |module| { + module.on_pre_route(&crate::context::HookContext { + peer_address: None, + url: url::Url::parse("gemini://localhost/").unwrap(), + parameters: None, + certificate: None, + }); + }); + }); + let advanced = progress.recv_timeout(std::time::Duration::from_secs(5)); + + release.send(()).unwrap(); + first.join().unwrap(); + second.join().unwrap(); + advanced.expect("the first module was blocked by the second module"); +} diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs index 97f43fa..95753b9 100644 --- a/tests/router_runtime.rs +++ b/tests/router_runtime.rs @@ -114,7 +114,7 @@ fn request(address: SocketAddr, path: &str, expected: &[u8]) { ); } -fn request_chunks(address: SocketAddr, chunks: &[&[u8]], expected: &[u8]) { +fn connect(address: SocketAddr) -> openssl::ssl::SslStream { let started = Instant::now(); let stream = loop { match TcpStream::connect_timeout(&address, Duration::from_millis(100)) { @@ -137,8 +137,18 @@ fn request_chunks(address: SocketAddr, chunks: &[&[u8]], expected: &[u8]) { let mut connector = SslConnector::builder(SslMethod::tls()).unwrap(); connector.set_verify(SslVerifyMode::NONE); + connector.build().connect("localhost", stream).unwrap() +} + +fn request_chunks(address: SocketAddr, chunks: &[&[u8]], expected: &[u8]) { + exchange(connect(address), chunks, expected); +} - let mut stream = connector.build().connect("localhost", stream).unwrap(); +fn exchange( + mut stream: openssl::ssl::SslStream, + chunks: &[&[u8]], + expected: &[u8], +) { let mut output = vec![0; expected.len()]; for chunk in chunks { @@ -373,3 +383,246 @@ async fn cloned_routers_retain_partials_and_running_servers_keep_their_snapshot( .await; } } + +#[derive(Default)] +struct HookGate { + state: std::sync::Mutex<(bool, Option)>, +} + +impl HookGate { + fn release(&self) { + let mut state = self.state.lock().unwrap(); + + state.0 = true; + + if let Some(waker) = state.1.take() { + waker.wake(); + } + } + + async fn wait_async(&self) { + std::future::poll_fn(|context| { + let mut state = self.state.lock().unwrap(); + + if state.0 { + std::task::Poll::Ready(()) + } else { + state.1 = Some(context.waker().clone()); + + std::task::Poll::Pending + } + }) + .await; + } +} + +struct GatedModule { + second: bool, + post_route: bool, + gate: Arc, + route_gate: Arc, + entered: std::sync::mpsc::Sender<()>, + progressed: std::sync::mpsc::Sender<()>, + events: Arc>>, +} + +impl GatedModule { + fn enter(&self, context: &HookContext, post_route: bool) -> bool { + if self.post_route != post_route { + return false; + } + + self + .events + .lock() + .unwrap() + .push((self.second, context.url.path().to_owned())); + + if self.second && context.url.path() == "/first" { + self.entered.send(()).unwrap(); + self.route_gate.release(); + + return true; + } + + if !self.second && context.url.path() == "/second" { + self.progressed.send(()).unwrap(); + } + + false + } +} + +#[async_trait::async_trait] +impl AsyncModule for GatedModule { + async fn on_pre_route(&mut self, context: &HookContext) { + if self.enter(context, false) { + self.gate.wait_async().await; + } + } + + async fn on_post_route(&mut self, context: &HookContext) { + if self.enter(context, true) { + self.gate.wait_async().await; + } + } +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn async_module_locks_allow_progress_in_both_hook_phases() { + for post_route in [false, true] { + let mut router = Router::new(); + let gate = Arc::new(HookGate::default()); + let route_gate = Arc::new(HookGate::default()); + let (route_entered, route_entry) = std::sync::mpsc::channel(); + let events = Arc::new(Mutex::new(Vec::new())); + let (entered, entry) = std::sync::mpsc::channel(); + let (progressed, progress) = std::sync::mpsc::channel(); + + router.set_error_handler(|_| Response::success("BODY")); + + let route_wait = route_gate.clone(); + + router.mount("/second", move |_| { + let route_wait = route_wait.clone(); + let route_entered = route_entered.clone(); + + async move { + if post_route { + route_entered.send(()).unwrap(); + route_wait.wait_async().await; + } + + Response::success("BODY") + } + }); + + for second in [false, true] { + let module = GatedModule { + second, + post_route, + gate: gate.clone(), + route_gate: route_gate.clone(), + entered: entered.clone(), + progressed: progressed.clone(), + events: events.clone(), + }; + + router.attach_async(module).await; + } + + serve_requests(router, move |address| { + let expected = b"20 text/gemini; charset=utf-8; lang=en\r\nBODY\n"; + let first_connection = connect(address); + let second_connection = connect(address); + let (start_second, second_start) = std::sync::mpsc::channel(); + let second = std::thread::spawn(move || { + second_start.recv_timeout(Duration::from_secs(5)).unwrap(); + exchange( + second_connection, + &[b"gemini://localhost/second\r\n"], + expected, + ); + }); + + if post_route { + start_second.send(()).unwrap(); + route_entry.recv_timeout(Duration::from_secs(5)).unwrap(); + } + + let first = std::thread::spawn(move || { + exchange( + first_connection, + &[b"gemini://localhost/first\r\n"], + expected, + ); + }); + + entry.recv_timeout(Duration::from_secs(5)).unwrap(); + + if !post_route { + start_second.send(()).unwrap(); + } + + let progressed = progress.recv_timeout(Duration::from_secs(5)); + + assert_eq!( + *events.lock().unwrap(), + [ + (false, "/first".to_owned()), + (true, "/first".to_owned()), + (false, "/second".to_owned()) + ] + ); + gate.release(); + first.join().unwrap(); + second.join().unwrap(); + progressed.expect("the first module was blocked by the second module"); + assert_eq!( + *events.lock().unwrap(), + [ + (false, "/first".to_owned()), + (true, "/first".to_owned()), + (false, "/second".to_owned()), + (true, "/second".to_owned()) + ] + ); + }) + .await; + } +} + +struct PanickingModule { + post_route: bool, +} + +impl Module for PanickingModule { + fn on_pre_route(&mut self, context: &HookContext) { + assert!( + self.post_route || context.url.path() != "/panic", + "pre-route panic" + ); + } + + fn on_post_route(&mut self, context: &HookContext) { + assert!( + !self.post_route || context.url.path() != "/panic", + "post-route panic" + ); + } +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn poisoned_modules_do_not_disable_other_modules() { + for post_route in [false, true] { + let mut router = Router::new(); + let events = Arc::new(Mutex::new(Vec::new())); + + router.set_error_handler(|_| Response::success("BODY")); + router.attach(PanickingModule { + post_route, + }); + router.attach(Observer(events.clone())); + events.lock().unwrap().clear(); + serve_requests(router, move |address| { + let expected = b"20 text/gemini; charset=utf-8; lang=en\r\nBODY\n"; + + assert!(std::panic::catch_unwind(|| { + request(address, "/panic", expected) + }) + .is_err()); + events.lock().unwrap().clear(); + request(address, "/healthy", expected); + assert_eq!(*events.lock().unwrap(), ["module-pre", "module-post"]); + }) + .await; + } +} From ee8ee8021b26cff375e98df8c5501e53a1c55c0c Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 07:36:40 +0000 Subject: [PATCH 20/40] feat(router)!: Make concurrent module scheduling opt-in BREAKING CHANGE: RouterOption gains AllowConcurrentModules, so exhaustive matches must handle the new variant. --- src/module.rs | 13 ++-- src/router.rs | 138 ++++++++++++++++++++++++++++++++------- src/router_option.rs | 9 +++ tests/router.rs | 139 +++++++++++++++++++++++++++++++++++++++- tests/router_runtime.rs | 1 + 5 files changed, 271 insertions(+), 29 deletions(-) diff --git a/src/module.rs b/src/module.rs index 3ee7881..5c68f43 100644 --- a/src/module.rs +++ b/src/module.rs @@ -1,8 +1,9 @@ use crate::context::HookContext; -/// Each module runs one hook at a time. Different modules may run concurrently -/// across requests, while each request visits modules in registration order. +/// Synchronous modules run hooks in registration order for each request. /// +/// Each module remains exclusive. Hook phases run exclusively across requests +/// unless `RouterOption::AllowConcurrentModules` is enabled. /// If a hook panics, subsequent requests skip that module because its lock is /// poisoned; other modules remain available. pub trait Module { @@ -19,9 +20,11 @@ pub trait Module { fn on_post_route(&mut self, _: &HookContext) {} } -/// Each module runs one hook at a time, including across awaits. Different -/// modules may run concurrently across requests, while each request visits -/// modules in registration order. +/// Asynchronous modules run hooks in registration order for each request. +/// +/// Each module remains exclusive, including across awaits. Hook phases run +/// exclusively across requests unless `RouterOption::AllowConcurrentModules` +/// is enabled. #[async_trait::async_trait] pub trait AsyncModule: Send + Sync { /// The router calls this hook during attachment, before registering diff --git a/src/router.rs b/src/router.rs index f691565..49daa30 100644 --- a/src/router.rs +++ b/src/router.rs @@ -10,7 +10,7 @@ use crate::{ #[cfg(feature = "async-std")] use async_std::{ io::{ReadExt, WriteExt}, - sync::Mutex as AsyncMutex, + sync::{Mutex as AsyncMutex, RwLock as AsyncRwLock}, }; use openssl::ssl::{self, SslAcceptor, SslMethod}; use std::{ @@ -18,13 +18,18 @@ use std::{ error::Error, fmt::Write, future::IntoFuture, - sync::{Arc, Mutex}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + Mutex, + RwLock, + }, time, }; #[cfg(feature = "tokio")] use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, - sync::Mutex as AsyncMutex, + sync::{Mutex as AsyncMutex, RwLock as AsyncRwLock}, }; use url::Url; @@ -59,6 +64,19 @@ type Stream = async_std_openssl::SslStream; type SharedModule = Arc>>; type SharedAsyncModule = Arc>>; +#[derive(Default)] +struct ModuleScheduling { + concurrent: AtomicBool, + synchronous: RwLock<()>, + asynchronous: AsyncRwLock<()>, +} + +#[derive(Clone, Copy)] +enum HookPhase { + PreRoute, + PostRoute, +} + /// A router dispatches requests through its handlers, partials, and modules. #[derive(Clone)] pub struct Router { @@ -83,6 +101,7 @@ pub struct Router { port: u16, async_modules: Arc>>, modules: Arc>>, + module_scheduling: Arc, options: HashSet, listener_address: String, } @@ -98,6 +117,7 @@ struct RequestHandler { languages_joined: String, async_modules: Arc>>, modules: Arc>>, + module_scheduling: Arc, options: HashSet, } @@ -164,13 +184,15 @@ impl RequestHandler { parameters: route.as_ref().ok().map(|route| route.parameters.clone()), certificate: peer_certificate.clone(), }; - let async_modules = self.async_modules.lock().await.clone(); - for module in async_modules { - module.lock().await.on_pre_route(&hook_context).await; - } - - call_modules(&self.modules, |module| { + call_async_modules( + &self.async_modules, + &self.module_scheduling, + &hook_context, + HookPhase::PreRoute, + ) + .await; + call_modules(&self.modules, &self.module_scheduling, |module| { module.on_pre_route(&hook_context); }); self.pre_route_callback.call(&hook_context); @@ -201,13 +223,15 @@ impl RequestHandler { )) .await }; - let async_modules = self.async_modules.lock().await.clone(); - - for module in async_modules { - module.lock().await.on_post_route(&hook_context).await; - } - call_modules(&self.modules, |module| { + call_async_modules( + &self.async_modules, + &self.module_scheduling, + &hook_context, + HookPhase::PostRoute, + ) + .await; + call_modules(&self.modules, &self.module_scheduling, |module| { module.on_post_route(&hook_context); }); self.post_route_callback.call(&hook_context, &mut content); @@ -227,16 +251,65 @@ impl RequestHandler { fn call_modules( modules: &Mutex>, + scheduling: &ModuleScheduling, hook: impl Fn(&mut dyn Module), ) { - let modules = modules.lock().ok().map(|modules| modules.clone()); + let invoke = || { + let modules = modules.lock().ok().map(|modules| modules.clone()); + + if let Some(modules) = modules { + for module in modules { + if let Ok(mut module) = module.lock() { + hook(module.as_mut()); + } + } + } + }; + + if scheduling.concurrent.load(Ordering::Relaxed) { + let _permit = scheduling + .synchronous + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + invoke(); + } else { + let _permit = scheduling + .synchronous + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + invoke(); + } +} + +async fn call_async_modules( + modules: &AsyncMutex>, + scheduling: &ModuleScheduling, + context: &HookContext, + phase: HookPhase, +) { + let invoke = async { + let modules = modules.lock().await.clone(); - if let Some(modules) = modules { for module in modules { - if let Ok(mut module) = module.lock() { - hook(module.as_mut()); + let mut module = module.lock().await; + + match phase { + HookPhase::PreRoute => module.on_pre_route(context).await, + HookPhase::PostRoute => module.on_post_route(context).await, } } + }; + + if scheduling.concurrent.load(Ordering::Relaxed) { + let _permit = scheduling.asynchronous.read().await; + + invoke.await; + } else { + let _permit = scheduling.asynchronous.write().await; + + invoke.await; } } @@ -647,6 +720,7 @@ impl Router { languages_joined: self.languages.join(","), async_modules: self.async_modules.clone(), modules: self.modules.clone(), + module_scheduling: self.module_scheduling.clone(), options: self.options.clone(), }); let acceptor = self.ssl_acceptor.clone(); @@ -1111,7 +1185,14 @@ impl Router { .expect("routes conflict under case-insensitive matching"); } - self.options.insert(*option); + if *option == RouterOption::AllowConcurrentModules { + self + .module_scheduling + .concurrent + .store(true, Ordering::Relaxed); + } else { + self.options.insert(*option); + } } self @@ -1135,7 +1216,12 @@ impl Router { /// ``` pub fn toggle_options(&mut self, options: &[RouterOption]) -> &mut Self { for option in options { - if self.options.contains(option) { + if *option == RouterOption::AllowConcurrentModules { + self + .module_scheduling + .concurrent + .fetch_xor(true, Ordering::Relaxed); + } else if self.options.contains(option) { self.remove_options(std::slice::from_ref(option)); } else { self.add_options(std::slice::from_ref(option)); @@ -1161,7 +1247,14 @@ impl Router { self.routes.disable_case_insensitive(); } - self.options.remove(option); + if *option == RouterOption::AllowConcurrentModules { + self + .module_scheduling + .concurrent + .store(false, Ordering::Relaxed); + } else { + self.options.remove(option); + } } self @@ -1220,6 +1313,7 @@ impl Default for Router { port: 1965, modules: Arc::new(Mutex::new(vec![])), async_modules: Arc::new(AsyncMutex::new(vec![])), + module_scheduling: Arc::new(ModuleScheduling::default()), options: HashSet::new(), private_key_content: None, certificate_content: None, diff --git a/src/router_option.rs b/src/router_option.rs index 68740b5..4eebf68 100644 --- a/src/router_option.rs +++ b/src/router_option.rs @@ -12,4 +12,13 @@ pub enum RouterOption { /// Routes that conflict under this matching rule cannot be registered /// together while the option is enabled. AllowCaseInsensitiveLookup, + /// If enabled, different modules may run concurrently across requests. + /// Each module remains exclusive. By default, each synchronous or + /// asynchronous hook phase runs exclusively across its module collection. + /// + /// This option is shared by cloned routers, including running servers. + /// Changes apply when a hook phase selects its scheduling mode. An exclusive + /// phase waits for any concurrent phases to finish before invoking hooks. + /// Poisoned synchronous modules are skipped in either mode. + AllowConcurrentModules, } diff --git a/tests/router.rs b/tests/router.rs index e5154f9..fcfde15 100644 --- a/tests/router.rs +++ b/tests/router.rs @@ -467,9 +467,16 @@ fn synchronous_module_locks_allow_other_modules_to_progress() { blocked: false, }))), ])); + let scheduling = std::sync::Arc::new(super::ModuleScheduling::default()); + let first_scheduling = scheduling.clone(); let first_modules = modules.clone(); + + scheduling + .concurrent + .store(true, std::sync::atomic::Ordering::Relaxed); + let first = std::thread::spawn(move || { - super::call_modules(&first_modules, |module| { + super::call_modules(&first_modules, &first_scheduling, |module| { module.on_pre_route(&crate::context::HookContext { peer_address: None, url: url::Url::parse("gemini://localhost/").unwrap(), @@ -487,7 +494,7 @@ fn synchronous_module_locks_allow_other_modules_to_progress() { .unwrap(); let second = std::thread::spawn(move || { - super::call_modules(&modules, |module| { + super::call_modules(&modules, &scheduling, |module| { module.on_pre_route(&crate::context::HookContext { peer_address: None, url: url::Url::parse("gemini://localhost/").unwrap(), @@ -503,3 +510,131 @@ fn synchronous_module_locks_allow_other_modules_to_progress() { second.join().unwrap(); advanced.expect("the first module was blocked by the second module"); } + +#[test] +fn module_scheduling_options_are_shared_across_router_clones() { + let mut router = super::Router::new(); + let mut cloned = router.clone(); + let enabled = || { + router + .module_scheduling + .concurrent + .load(std::sync::atomic::Ordering::Relaxed) + }; + + assert!(!enabled()); + cloned + .add_options(&[crate::router_option::RouterOption::AllowConcurrentModules]); + assert!(enabled()); + cloned.toggle_options(&[ + crate::router_option::RouterOption::AllowConcurrentModules, + ]); + assert!(!enabled()); + cloned.toggle_options(&[ + crate::router_option::RouterOption::AllowConcurrentModules, + ]); + assert!(enabled()); + router.remove_options(&[ + crate::router_option::RouterOption::AllowConcurrentModules, + ]); + assert!(!cloned + .module_scheduling + .concurrent + .load(std::sync::atomic::Ordering::Relaxed)); +} + +#[test] +fn synchronous_hook_phases_take_exclusive_or_shared_permits() { + let mut router = super::Router::new(); + + router.attach(SchedulingProbe); + super::call_modules(&router.modules, &router.module_scheduling, |_| { + assert!(router.module_scheduling.synchronous.try_read().is_err()); + }); + router + .add_options(&[crate::router_option::RouterOption::AllowConcurrentModules]); + super::call_modules(&router.modules, &router.module_scheduling, |_| { + assert!(router.module_scheduling.synchronous.try_read().is_ok()); + assert!(router.module_scheduling.synchronous.try_write().is_err()); + }); +} + +struct SchedulingProbe; + +impl crate::module::Module for SchedulingProbe {} + +#[async_trait::async_trait] +impl crate::module::AsyncModule for SchedulingProbe {} + +#[cfg_attr(feature = "tokio", tokio::test(flavor = "current_thread"))] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn exclusive_async_phases_wait_for_existing_concurrent_phases() { + use std::{future::Future, pin::pin, task::Poll}; + + let mut router = super::Router::new(); + let context = crate::context::HookContext { + peer_address: None, + url: url::Url::parse("gemini://localhost/").unwrap(), + parameters: None, + certificate: None, + }; + + router.attach_async(SchedulingProbe).await; + + for phase in [super::HookPhase::PreRoute, super::HookPhase::PostRoute] { + let shared = router.module_scheduling.asynchronous.read().await; + let mut dispatch = pin!(super::call_async_modules( + &router.async_modules, + &router.module_scheduling, + &context, + phase, + )); + + std::future::poll_fn(|context| { + assert!(dispatch.as_mut().poll(context).is_pending()); + Poll::Ready(()) + }) + .await; + drop(shared); + + dispatch.await; + } + + router + .add_options(&[crate::router_option::RouterOption::AllowConcurrentModules]); + + let scheduling = router.module_scheduling.clone(); + let modules = router.async_modules.clone(); + let shared = scheduling.asynchronous.read().await; + let mut concurrent = pin!(super::call_async_modules( + &modules, + &scheduling, + &context, + super::HookPhase::PreRoute, + )); + + std::future::poll_fn(|context| { + assert!(concurrent.as_mut().poll(context).is_ready()); + Poll::Ready(()) + }) + .await; + router.remove_options(&[ + crate::router_option::RouterOption::AllowConcurrentModules, + ]); + + let mut exclusive = pin!(super::call_async_modules( + &modules, + &scheduling, + &context, + super::HookPhase::PostRoute, + )); + + std::future::poll_fn(|context| { + assert!(exclusive.as_mut().poll(context).is_pending()); + Poll::Ready(()) + }) + .await; + drop(shared); + + exclusive.await; +} diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs index 95753b9..3ef1b05 100644 --- a/tests/router_runtime.rs +++ b/tests/router_runtime.rs @@ -483,6 +483,7 @@ async fn async_module_locks_allow_progress_in_both_hook_phases() { let (entered, entry) = std::sync::mpsc::channel(); let (progressed, progress) = std::sync::mpsc::channel(); + router.add_options(&[RouterOption::AllowConcurrentModules]); router.set_error_handler(|_| Response::success("BODY")); let route_wait = route_gate.clone(); From 1dcddc1c9ab197f76234e8f86db2b90daf371a5f Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 07:43:43 +0000 Subject: [PATCH 21/40] fix(rossweisse): Preserve declared router visibility --- .../src/implementations/router/fields.rs | 3 ++- tests/consumer.rs | 8 +++++- tests/consumer/src/bin/visibility.rs | 25 ++++++++++++++++++- tests/consumer/src/bin/visibility_private.rs | 6 +++++ .../consumer/src/bin/visibility_restricted.rs | 8 ++++++ 5 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 tests/consumer/src/bin/visibility_private.rs create mode 100644 tests/consumer/src/bin/visibility_restricted.rs diff --git a/rossweisse/src/implementations/router/fields.rs b/rossweisse/src/implementations/router/fields.rs index fb0d9fa..9ac786c 100644 --- a/rossweisse/src/implementations/router/fields.rs +++ b/rossweisse/src/implementations/router/fields.rs @@ -5,6 +5,7 @@ use syn::punctuated::Punctuated; pub fn fields(arguments: TokenStream, item: syn::ItemStruct) -> TokenStream { let field_initializers = syn::parse_macro_input!(arguments as super::parser::FieldInitializers); + let visibility = item.vis; let router_identifier = item.ident; let named_fields = match item.fields { syn::Fields::Named(fields) => fields, @@ -46,7 +47,7 @@ pub fn fields(arguments: TokenStream, item: syn::ItemStruct) -> TokenStream { }; let output_fields = named_fields.named; let output = quote! { - struct #router_identifier { + #visibility struct #router_identifier { #output_fields router: ::windmark::router::Router, } diff --git a/tests/consumer.rs b/tests/consumer.rs index 81c7204..599be1f 100644 --- a/tests/consumer.rs +++ b/tests/consumer.rs @@ -23,7 +23,13 @@ fn consumer_api_and_macro_forms_match_expected_behavior() { "cannot return reference to function parameter", false, ), - ("visibility", "struct `Capsule` is private", false), + ("visibility", "", false), + ("visibility_private", "struct `Capsule` is private", false), + ( + "visibility_restricted", + "struct `Capsule` is private", + false, + ), ( "index_name", "no function or associated item named `index`", diff --git a/tests/consumer/src/bin/visibility.rs b/tests/consumer/src/bin/visibility.rs index 189155e..3d6a31a 100644 --- a/tests/consumer/src/bin/visibility.rs +++ b/tests/consumer/src/bin/visibility.rs @@ -1,6 +1,29 @@ mod capsule { #[rossweisse::router] pub struct Capsule; + + #[rossweisse::router] + pub(crate) struct Stateful { + visits: usize, + } + + mod nested { + #[rossweisse::router] + pub(super) struct ParentVisible; + + #[rossweisse::router] + pub(in crate::capsule) struct Scoped; + } + + pub fn check_nested_visibility() { + let _: Option = None; + let _: Option = None; + } } -fn main() { let _: Option = None; } +fn main() { + let _: Option = None; + let _: Option = None; + + capsule::check_nested_visibility(); +} diff --git a/tests/consumer/src/bin/visibility_private.rs b/tests/consumer/src/bin/visibility_private.rs new file mode 100644 index 0000000..c9adc37 --- /dev/null +++ b/tests/consumer/src/bin/visibility_private.rs @@ -0,0 +1,6 @@ +mod capsule { + #[rossweisse::router] + struct Capsule; +} + +fn main() { let _: Option = None; } diff --git a/tests/consumer/src/bin/visibility_restricted.rs b/tests/consumer/src/bin/visibility_restricted.rs new file mode 100644 index 0000000..f5fb1dd --- /dev/null +++ b/tests/consumer/src/bin/visibility_restricted.rs @@ -0,0 +1,8 @@ +mod capsule { + pub mod nested { + #[rossweisse::router] + pub(in crate::capsule) struct Capsule; + } +} + +fn main() { let _: Option = None; } From cff21834f31709f8c216f7a282ccc82c535cbe6f Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 07:45:29 +0000 Subject: [PATCH 22/40] fix(rossweisse): Report macro errors at the offending syntax --- .../src/implementations/router/fields.rs | 14 ++++++---- rossweisse/src/lib.rs | 26 ++++++++++++------- tests/consumer.rs | 21 +++++++++++++++ tests/consumer/src/bin/invalid_route_item.rs | 4 +++ .../consumer/src/bin/invalid_route_syntax.rs | 10 +++++++ .../consumer/src/bin/invalid_router_fields.rs | 4 +++ tests/consumer/src/bin/invalid_router_item.rs | 4 +++ .../consumer/src/bin/invalid_router_syntax.rs | 10 +++++++ 8 files changed, 78 insertions(+), 15 deletions(-) create mode 100644 tests/consumer/src/bin/invalid_route_item.rs create mode 100644 tests/consumer/src/bin/invalid_route_syntax.rs create mode 100644 tests/consumer/src/bin/invalid_router_fields.rs create mode 100644 tests/consumer/src/bin/invalid_router_item.rs create mode 100644 tests/consumer/src/bin/invalid_router_syntax.rs diff --git a/rossweisse/src/implementations/router/fields.rs b/rossweisse/src/implementations/router/fields.rs index 9ac786c..9c7912e 100644 --- a/rossweisse/src/implementations/router/fields.rs +++ b/rossweisse/src/implementations/router/fields.rs @@ -16,11 +16,15 @@ pub fn fields(arguments: TokenStream, item: syn::ItemStruct) -> TokenStream { named: Punctuated::default(), }, - syn::Fields::Unnamed(_) => - panic!( - "`#[rossweisse::router]` can only be used on `struct`s with named \ - fields or unit structs" - ), + syn::Fields::Unnamed(fields) => { + return syn::Error::new_spanned( + fields, + "`#[rossweisse::router]` requires a struct with named fields or a \ + unit struct", + ) + .to_compile_error() + .into(); + } }; let new_method_fields = named_fields.named.iter().map(|field| { let name = &field.ident; diff --git a/rossweisse/src/lib.rs b/rossweisse/src/lib.rs index 9589435..fddc2f0 100644 --- a/rossweisse/src/lib.rs +++ b/rossweisse/src/lib.rs @@ -17,10 +17,6 @@ use syn::Item; /// Mark a `struct` as a router or an `impl` block as a router implementation. /// -/// # Panics -/// -/// Panics if used on an item that is not a `struct` or `impl` block. -/// /// # Examples /// /// ```rust @@ -45,16 +41,19 @@ pub fn router(arguments: TokenStream, item: TokenStream) -> TokenStream { match syn::parse::(item) { Ok(Item::Struct(item)) => implementations::fields(arguments, item), Ok(Item::Impl(item)) => implementations::methods(arguments, item), - _ => panic!("`#[rossweisse::router]` can only be used on `struct`s"), + Ok(item) => + syn::Error::new_spanned( + item, + "`#[rossweisse::router]` requires a struct or impl block", + ) + .to_compile_error() + .into(), + Err(error) => error.to_compile_error().into(), } } /// Mark a method of a router implementation as a route to mount. /// -/// # Panics -/// -/// Panics if used on an item that is not a function. -/// /// # Examples /// /// ```rust @@ -76,6 +75,13 @@ pub fn router(arguments: TokenStream, item: TokenStream) -> TokenStream { pub fn route(arguments: TokenStream, item: TokenStream) -> TokenStream { match syn::parse::(item) { Ok(Item::Fn(ref item)) => implementations::route(arguments, item), - _ => panic!("`#[rossweisse::route]` can only be used on `fn`s"), + Ok(item) => + syn::Error::new_spanned( + item, + "`#[rossweisse::route]` requires a function", + ) + .to_compile_error() + .into(), + Err(error) => error.to_compile_error().into(), } } diff --git a/tests/consumer.rs b/tests/consumer.rs index 599be1f..2cb0756 100644 --- a/tests/consumer.rs +++ b/tests/consumer.rs @@ -23,6 +23,19 @@ fn consumer_api_and_macro_forms_match_expected_behavior() { "cannot return reference to function parameter", false, ), + ( + "invalid_router_item", + "requires a struct or impl block", + false, + ), + ("invalid_route_item", "requires a function", false), + ( + "invalid_router_fields", + "requires a struct with named fields or a unit struct", + false, + ), + ("invalid_router_syntax", "expected", false), + ("invalid_route_syntax", "expected", false), ("visibility", "", false), ("visibility_private", "struct `Capsule` is private", false), ( @@ -58,6 +71,14 @@ fn consumer_api_and_macro_forms_match_expected_behavior() { .unwrap(); let errors = String::from_utf8_lossy(&output.stderr); + if name.starts_with("invalid_") { + assert!( + !errors.contains("custom attribute panicked"), + "{name}: {errors}" + ); + assert!(errors.contains(&format!("{name}.rs")), "{name}: {errors}"); + } + if diagnostic.is_empty() { assert!(output.status.success(), "{name}: {errors}"); } else { diff --git a/tests/consumer/src/bin/invalid_route_item.rs b/tests/consumer/src/bin/invalid_route_item.rs new file mode 100644 index 0000000..ea533b6 --- /dev/null +++ b/tests/consumer/src/bin/invalid_route_item.rs @@ -0,0 +1,4 @@ +#[rossweisse::route] +struct Route; + +fn main() {} diff --git a/tests/consumer/src/bin/invalid_route_syntax.rs b/tests/consumer/src/bin/invalid_route_syntax.rs new file mode 100644 index 0000000..bec30a8 --- /dev/null +++ b/tests/consumer/src/bin/invalid_route_syntax.rs @@ -0,0 +1,10 @@ +macro_rules! invalid_declaration { + () => { + #[rossweisse::route] + fn route(value: ) {} + }; +} + +invalid_declaration!(); + +fn main() {} diff --git a/tests/consumer/src/bin/invalid_router_fields.rs b/tests/consumer/src/bin/invalid_router_fields.rs new file mode 100644 index 0000000..72e57f4 --- /dev/null +++ b/tests/consumer/src/bin/invalid_router_fields.rs @@ -0,0 +1,4 @@ +#[rossweisse::router] +struct Capsule(usize); + +fn main() {} diff --git a/tests/consumer/src/bin/invalid_router_item.rs b/tests/consumer/src/bin/invalid_router_item.rs new file mode 100644 index 0000000..0c1e153 --- /dev/null +++ b/tests/consumer/src/bin/invalid_router_item.rs @@ -0,0 +1,4 @@ +#[rossweisse::router] +enum Capsule {} + +fn main() {} diff --git a/tests/consumer/src/bin/invalid_router_syntax.rs b/tests/consumer/src/bin/invalid_router_syntax.rs new file mode 100644 index 0000000..3eb7f34 --- /dev/null +++ b/tests/consumer/src/bin/invalid_router_syntax.rs @@ -0,0 +1,10 @@ +macro_rules! invalid_declaration { + () => { + #[rossweisse::router] + struct Capsule { value: , } + }; +} + +invalid_declaration!(); + +fn main() {} From 79ae7ccb6601d80f874d53820b45000cc079dbe2 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 07:52:04 +0000 Subject: [PATCH 23/40] fix(rossweisse)!: Preserve index route method names BREAKING CHANGE: Callers of the generated __router_index method must use the declared handler name. An ordinary route explicitly named __router_index now mounts at /__router_index rather than /. --- .../src/implementations/router/methods.rs | 36 ++++---------- rossweisse/src/lib.rs | 5 +- tests/consumer.rs | 6 +-- tests/consumer/src/bin/valid.rs | 2 +- tests/router_runtime.rs | 47 +++++++++++++++++++ 5 files changed, 63 insertions(+), 33 deletions(-) diff --git a/rossweisse/src/implementations/router/methods.rs b/rossweisse/src/implementations/router/methods.rs index cc1d36a..89aca3b 100644 --- a/rossweisse/src/implementations/router/methods.rs +++ b/rossweisse/src/implementations/router/methods.rs @@ -1,12 +1,9 @@ use proc_macro::TokenStream; -pub fn methods( - _arguments: TokenStream, - mut item: syn::ItemImpl, -) -> TokenStream { - let routes = item +pub fn methods(_arguments: TokenStream, item: &syn::ItemImpl) -> TokenStream { + let (routes, route_paths): (Vec<_>, Vec<_>) = item .items - .iter_mut() + .iter() .filter_map(|item| { let syn::ImplItem::Fn(method) = item else { return None; @@ -18,31 +15,18 @@ pub fn methods( let is_index = route_attribute .parse_args::() .is_ok_and(|argument| argument == "index"); + let path = if is_index { + "/".to_owned() + } else { + format!("/{}", method.sig.ident) + }; - if is_index { - method.sig.ident = - syn::Ident::new("__router_index", method.sig.ident.span()); - } - - Some(method.sig.ident.clone()) + Some((&method.sig.ident, path)) }) - .collect::>(); + .unzip(); let (implementation_generics, type_generics, where_clause) = item.generics.split_for_impl(); let name = &item.self_ty; - let route_paths = routes - .iter() - .map(|route| { - format!( - "/{}", - if route == "__router_index" { - String::new() - } else { - route.to_string() - } - ) - }) - .collect::>(); quote::quote! { #item diff --git a/rossweisse/src/lib.rs b/rossweisse/src/lib.rs index fddc2f0..a56c1af 100644 --- a/rossweisse/src/lib.rs +++ b/rossweisse/src/lib.rs @@ -40,7 +40,7 @@ use syn::Item; pub fn router(arguments: TokenStream, item: TokenStream) -> TokenStream { match syn::parse::(item) { Ok(Item::Struct(item)) => implementations::fields(arguments, item), - Ok(Item::Impl(item)) => implementations::methods(arguments, item), + Ok(Item::Impl(ref item)) => implementations::methods(arguments, item), Ok(item) => syn::Error::new_spanned( item, @@ -54,6 +54,9 @@ pub fn router(arguments: TokenStream, item: TokenStream) -> TokenStream { /// Mark a method of a router implementation as a route to mount. /// +/// Within a router implementation, `#[route(index)]` mounts the method at `/` +/// without changing its name. +/// /// # Examples /// /// ```rust diff --git a/tests/consumer.rs b/tests/consumer.rs index 2cb0756..dc2aaa4 100644 --- a/tests/consumer.rs +++ b/tests/consumer.rs @@ -43,11 +43,7 @@ fn consumer_api_and_macro_forms_match_expected_behavior() { "struct `Capsule` is private", false, ), - ( - "index_name", - "no function or associated item named `index`", - false, - ), + ("index_name", "", false), ]; for (name, diagnostic, automatic_mime) in cases { diff --git a/tests/consumer/src/bin/valid.rs b/tests/consumer/src/bin/valid.rs index 030c56f..5ffcfe6 100644 --- a/tests/consumer/src/bin/valid.rs +++ b/tests/consumer/src/bin/valid.rs @@ -127,7 +127,7 @@ fn main() { router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); assert_eq!(capsule.count, 7); assert!(capsule.label.is_empty()); - assert_eq!(Capsule::__router_index(context()).content, "index"); + assert_eq!(Capsule::index(context()).content, "index"); let mut response = Response::input::("prompt".to_owned()); diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs index 3ef1b05..aa3f718 100644 --- a/tests/router_runtime.rs +++ b/tests/router_runtime.rs @@ -627,3 +627,50 @@ async fn poisoned_modules_do_not_disable_other_modules() { .await; } } + +mod named_routes { + use rossweisse::route; + use windmark::{context::RouteContext, response::Response}; + + #[rossweisse::router] + pub struct Capsule; + + #[rossweisse::router] + impl Capsule { + #[route(index)] + pub fn index(_: RouteContext) -> Response { Response::success("INDEX") } + + #[route] + pub fn about(_: RouteContext) -> Response { Response::success("ABOUT") } + + #[route] + pub fn __router_index(_: RouteContext) -> Response { + Response::success("ORDINARY") + } + } +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn index_route_attributes_preserve_names_and_select_the_root_path() { + let mut capsule = named_routes::Capsule::new(); + + serve_requests(capsule.router().clone(), |address| { + for (path, body) in [ + ("/", "INDEX"), + ("/about", "ABOUT"), + ("/__router_index", "ORDINARY"), + ] { + request( + address, + path, + format!("20 text/gemini; charset=utf-8; lang=en\r\n{body}\n") + .as_bytes(), + ); + } + }) + .await; +} From 60ccfe6c14571e9a1f311373835cd8c53e34bb53 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 09:33:22 +0000 Subject: [PATCH 24/40] fix(macros): Repair context binding and MIME inference --- src/response/macros.rs | 60 +++++++------------ tests/consumer.rs | 39 ++++++------ tests/consumer/Cargo.toml | 3 +- tests/consumer/src/bin/async_context.rs | 36 ++++++++++- .../src/bin/automatic_mime_disabled.rs | 1 + tests/consumer/src/bin/binary_context.rs | 7 ++- 6 files changed, 84 insertions(+), 62 deletions(-) create mode 100644 tests/consumer/src/bin/automatic_mime_disabled.rs diff --git a/src/response/macros.rs b/src/response/macros.rs index 566ad1c..aae1b05 100644 --- a/src/response/macros.rs +++ b/src/response/macros.rs @@ -1,7 +1,7 @@ macro_rules! sync_response { ($($name:ident),*) => { $( - /// Trailing commas are not supported at the moment! + /// This macro does not accept a trailing comma. #[macro_export] macro_rules! $name { ($body:expr) => { @@ -18,14 +18,20 @@ macro_rules! sync_response { macro_rules! async_response { ($($name:ident),*) => { $(::paste::paste! { - /// Trailing commas are not supported at the moment! + /// This macro does not accept a trailing comma. #[macro_export] macro_rules! [< $name _async >] { ($body:expr) => { |_: $crate::context::RouteContext| async { $crate::response::Response::$name($body) } }; ($context:ident, $body:expr) => { - |$context: $crate::context::RouteContext| async { $crate::response::Response::$name($body) } + |$context: $crate::context::RouteContext| async { + // Move the request context while preserving borrows of other captures. + #[allow(clippy::redundant_locals)] + let $context = $context; + + $crate::response::Response::$name($body) + } }; } })* @@ -65,53 +71,31 @@ response!( #[cfg(feature = "auto-deduce-mime")] response!(binary_success_auto); -/// Trailing commas are not supported at the moment! +/// Build a binary response handler with an explicit or inferred MIME type. +/// +/// Automatic inference requires Windmark's `auto-deduce-mime` feature. Use +/// `binary_success!(context => body)` to bind a context when inferring MIME; +/// the two-expression `(body, mime)` form always specifies MIME explicitly. #[macro_export] macro_rules! binary_success { - ($body:expr, $mime:expr) => { + ($body:expr, $mime:expr $(,)?) => { |_: $crate::context::RouteContext| { $crate::response::Response::binary_success($body, $mime) } }; - ($body:expr) => {{ - #[cfg(not(feature = "auto-deduce-mime"))] - compile_error!( - "`binary_success` without a MIME type requires the `auto-deduce-mime` \ - feature to be enabled" - ); - + ($body:expr $(,)?) => { |_: $crate::context::RouteContext| { - #[cfg(feature = "auto-deduce-mime")] - return $crate::response::Response::binary_success_auto($body); - - #[cfg(not(feature = "auto-deduce-mime"))] - $crate::response::Response::binary_success( - $body, - "application/octet-stream", - ) + $crate::response::Response::binary_success_auto($body) } - }}; - ($context:ident, $body:expr, $mime:expr) => { + }; + ($context:ident, $body:expr, $mime:expr $(,)?) => { |$context: $crate::context::RouteContext| { $crate::response::Response::binary_success($body, $mime) } }; - ($context:ident, $body:expr) => {{ - #[cfg(not(feature = "auto-deduce-mime"))] - compile_error!( - "`binary_success` without a MIME type requires the `auto-deduce-mime` \ - feature to be enabled" - ); - + ($context:ident => $body:expr $(,)?) => { |$context: $crate::context::RouteContext| { - #[cfg(feature = "auto-deduce-mime")] - return $crate::response::Response::binary_success_auto($body); - - #[cfg(not(feature = "auto-deduce-mime"))] - $crate::response::Response::binary_success( - $body, - "application/octet-stream", - ) + $crate::response::Response::binary_success_auto($body) } - }}; + }; } diff --git a/tests/consumer.rs b/tests/consumer.rs index dc2aaa4..89cdc1d 100644 --- a/tests/consumer.rs +++ b/tests/consumer.rs @@ -15,42 +15,39 @@ fn consumer_api_and_macro_forms_match_expected_behavior() { #[cfg(feature = "async-std")] let runtime = "async-std"; let cases = [ - ("valid", "", true), - ("automatic_mime", "requires the `auto-deduce-mime`", false), - ("binary_context", "cannot find value `context`", false), - ( - "async_context", - "cannot return reference to function parameter", - false, - ), + ("valid", "", "mime,auto-deduce-mime"), + ("automatic_mime", "", "mime"), + ("binary_context", "", "mime"), + ("async_context", "", "mime"), + ("automatic_mime_disabled", "binary_success_auto", ""), ( "invalid_router_item", "requires a struct or impl block", - false, + "mime", ), - ("invalid_route_item", "requires a function", false), + ("invalid_route_item", "requires a function", "mime"), ( "invalid_router_fields", "requires a struct with named fields or a unit struct", - false, + "mime", ), - ("invalid_router_syntax", "expected", false), - ("invalid_route_syntax", "expected", false), - ("visibility", "", false), - ("visibility_private", "struct `Capsule` is private", false), + ("invalid_router_syntax", "expected", "mime"), + ("invalid_route_syntax", "expected", "mime"), + ("visibility", "", "mime"), + ("visibility_private", "struct `Capsule` is private", "mime"), ( "visibility_restricted", "struct `Capsule` is private", - false, + "mime", ), - ("index_name", "", false), + ("index_name", "", "mime"), ]; - for (name, diagnostic, automatic_mime) in cases { - let features = if automatic_mime { - format!("{runtime},auto-deduce-mime") - } else { + for (name, diagnostic, extra_features) in cases { + let features = if extra_features.is_empty() { runtime.to_owned() + } else { + format!("{runtime},{extra_features}") }; let output = Command::new(env!("CARGO")) .arg(if diagnostic.is_empty() { diff --git a/tests/consumer/Cargo.toml b/tests/consumer/Cargo.toml index a04cad0..caa2bb9 100644 --- a/tests/consumer/Cargo.toml +++ b/tests/consumer/Cargo.toml @@ -10,9 +10,10 @@ publish = false tokio = ["windmark/tokio", "dep:tokio"] async-std = ["windmark/async-std"] auto-deduce-mime = [] +mime = ["windmark/auto-deduce-mime"] [dependencies] -windmark = { path = "../..", default-features = false, features = ["response-macros", "auto-deduce-mime"] } +windmark = { path = "../..", default-features = false, features = ["response-macros"] } rossweisse = { path = "../../rossweisse" } async-trait = "0.1.68" tokio = { version = "1.52.3", optional = true, features = ["macros", "rt"] } diff --git a/tests/consumer/src/bin/async_context.rs b/tests/consumer/src/bin/async_context.rs index 8e9a0cf..2973188 100644 --- a/tests/consumer/src/bin/async_context.rs +++ b/tests/consumer/src/bin/async_context.rs @@ -1 +1,35 @@ -fn main() { let _ = windmark::success_async!(context, context.url.path()); } +use std::future::Future; +use windmark::context::RouteContext; + +fn main() { + let greeting = String::from("Hello"); + let route = windmark::success_async!( + context, + format!("{greeting} {}", context.url.path()) + ); + + for path in ["/one", "/two"] { + let context = RouteContext { + peer_address: None, + url: format!("gemini://localhost{path}").parse().unwrap(), + parameters: Default::default(), + certificate: None, + }; + let future = route(context); + let mut future = std::pin::pin!(future); + let mut task = std::task::Context::from_waker(std::task::Waker::noop()); + let std::task::Poll::Ready(response) = future.as_mut().poll(&mut task) + else { + panic!("the response should be ready"); + }; + + assert_eq!(response.content, format!("Hello {path}")); + } + + let route = windmark::success_async!( + context, + std::future::ready(context.url.path()).await + ); + + windmark::router::Router::new().mount("/", route); +} diff --git a/tests/consumer/src/bin/automatic_mime_disabled.rs b/tests/consumer/src/bin/automatic_mime_disabled.rs new file mode 100644 index 0000000..34a8aac --- /dev/null +++ b/tests/consumer/src/bin/automatic_mime_disabled.rs @@ -0,0 +1 @@ +fn main() { let _ = windmark::binary_success!(b"hello"); } diff --git a/tests/consumer/src/bin/binary_context.rs b/tests/consumer/src/bin/binary_context.rs index c55d152..866d871 100644 --- a/tests/consumer/src/bin/binary_context.rs +++ b/tests/consumer/src/bin/binary_context.rs @@ -1,3 +1,8 @@ fn main() { - let _ = windmark::binary_success!(context, context.url.path().as_bytes()); + let body = b"hello"; + let mime = "text/plain"; + let _ = windmark::binary_success!(body, mime); + let _ = + windmark::binary_success!(context, context.url.path().as_bytes(), mime); + let _ = windmark::binary_success!(context => context.url.path().as_bytes()); } From 0def3814869e743d2de22f631784427ce8405429 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 09:33:46 +0000 Subject: [PATCH 25/40] fix(rossweisse)!: Preserve router declarations and generics BREAKING CHANGE: Invalid or duplicate field initialisers and reserved names now produce errors. --- rossweisse/src/implementations/route.rs | 15 ++- rossweisse/src/implementations/router.rs | 42 +++++++ .../src/implementations/router/fields.rs | 110 ++++++++++++------ .../src/implementations/router/methods.rs | 83 ++++++++----- rossweisse/src/lib.rs | 5 + tests/consumer.rs | 17 +++ .../src/bin/invalid_route_argument.rs | 4 + .../src/bin/invalid_router_duplicate.rs | 6 + .../src/bin/invalid_router_reserved.rs | 6 + .../src/bin/invalid_router_unknown.rs | 4 + tests/consumer/src/bin/router_declarations.rs | 39 +++++++ tests/consumer/src/bin/valid.rs | 2 +- tests/router_runtime.rs | 2 +- 13 files changed, 267 insertions(+), 68 deletions(-) create mode 100644 tests/consumer/src/bin/invalid_route_argument.rs create mode 100644 tests/consumer/src/bin/invalid_router_duplicate.rs create mode 100644 tests/consumer/src/bin/invalid_router_reserved.rs create mode 100644 tests/consumer/src/bin/invalid_router_unknown.rs create mode 100644 tests/consumer/src/bin/router_declarations.rs diff --git a/rossweisse/src/implementations/route.rs b/rossweisse/src/implementations/route.rs index d38df93..edfa9a0 100644 --- a/rossweisse/src/implementations/route.rs +++ b/rossweisse/src/implementations/route.rs @@ -1,5 +1,18 @@ use proc_macro::TokenStream; -pub fn route(_arguments: TokenStream, item: &syn::ItemFn) -> TokenStream { +pub fn route(arguments: TokenStream, item: &syn::ItemFn) -> TokenStream { + if !arguments.is_empty() { + let argument = syn::parse_macro_input!(arguments as syn::Ident); + + if argument != "index" { + return syn::Error::new_spanned( + argument, + "the only supported route argument is `index`", + ) + .to_compile_error() + .into(); + } + } + quote::quote! { #item }.into() } diff --git a/rossweisse/src/implementations/router.rs b/rossweisse/src/implementations/router.rs index f7b6094..2d330c2 100644 --- a/rossweisse/src/implementations/router.rs +++ b/rossweisse/src/implementations/router.rs @@ -1,6 +1,48 @@ +use syn::{punctuated::Punctuated, Meta, Token}; + mod fields; mod methods; mod parser; pub use fields::fields; pub use methods::methods; + +fn conditional_attributes( + attributes: &[syn::Attribute], +) -> Vec { + attributes + .iter() + .filter_map(|attribute| { + conditional_meta(&attribute.meta).map(|meta| syn::parse_quote!(#[#meta])) + }) + .collect() +} + +fn conditional_meta(meta: &syn::Meta) -> Option { + if meta.path().is_ident("cfg") { + return Some(meta.clone()); + } + + let syn::Meta::List(list) = meta else { + return None; + }; + + if !list.path.is_ident("cfg_attr") { + return None; + } + + let arguments = list + .parse_args_with(Punctuated::::parse_terminated) + .ok()?; + let mut arguments = arguments.into_iter(); + let predicate = arguments.next()?; + let conditions: Vec<_> = arguments + .filter_map(|meta| conditional_meta(&meta)) + .collect(); + + if conditions.is_empty() { + None + } else { + Some(syn::parse_quote!(cfg_attr(#predicate, #(#conditions),*))) + } +} diff --git a/rossweisse/src/implementations/router/fields.rs b/rossweisse/src/implementations/router/fields.rs index 9c7912e..b3f68a4 100644 --- a/rossweisse/src/implementations/router/fields.rs +++ b/rossweisse/src/implementations/router/fields.rs @@ -1,33 +1,70 @@ use proc_macro::TokenStream; use quote::quote; -use syn::punctuated::Punctuated; +use std::collections::HashSet; -pub fn fields(arguments: TokenStream, item: syn::ItemStruct) -> TokenStream { +pub fn fields( + arguments: TokenStream, + mut item: syn::ItemStruct, +) -> TokenStream { let field_initializers = syn::parse_macro_input!(arguments as super::parser::FieldInitializers); - let visibility = item.vis; - let router_identifier = item.ident; - let named_fields = match item.fields { - syn::Fields::Named(fields) => fields, - syn::Fields::Unit => - syn::FieldsNamed { - brace_token: syn::token::Brace::default(), - named: Punctuated::default(), - }, + if matches!(item.fields, syn::Fields::Unit) { + item.fields = syn::Fields::Named(syn::parse_quote!({})); + item.semi_token = None; + } - syn::Fields::Unnamed(fields) => { + let syn::Fields::Named(fields) = &mut item.fields else { + return syn::Error::new_spanned( + item.fields, + "`#[rossweisse::router]` requires a struct with named fields or a unit \ + struct", + ) + .to_compile_error() + .into(); + }; + let mut supplied_fields = HashSet::new(); + + for initializer in &field_initializers.0 { + if !supplied_fields.insert(initializer.identifier.to_string()) { return syn::Error::new_spanned( - fields, - "`#[rossweisse::router]` requires a struct with named fields or a \ - unit struct", + &initializer.identifier, + "duplicate field initialiser", ) .to_compile_error() .into(); } - }; - let new_method_fields = named_fields.named.iter().map(|field| { + + if !fields + .named + .iter() + .any(|field| field.ident.as_ref() == Some(&initializer.identifier)) + { + return syn::Error::new_spanned( + &initializer.identifier, + "unknown field initialiser", + ) + .to_compile_error() + .into(); + } + } + + if let Some(field) = fields + .named + .iter() + .find(|field| field.ident.as_ref().is_some_and(|name| name == "router")) + { + return syn::Error::new_spanned( + field, + "the field name `router` is reserved by Rossweisse", + ) + .to_compile_error() + .into(); + } + + let initializers = fields.named.iter().map(|field| { let name = &field.ident; + let conditions = super::conditional_attributes(&field.attrs); let initializer: syn::Expr = field_initializers .0 .iter() @@ -38,26 +75,32 @@ pub fn fields(arguments: TokenStream, item: syn::ItemStruct) -> TokenStream { ); quote! { + #(#conditions)* #name: #initializer, } }); - let new_methods = quote! { - fn _new() -> Self { - Self { - #(#new_method_fields)* - router: ::windmark::router::Router::new(), - } + let initialization = quote! { + Self { + #(#initializers)* + router: ::windmark::router::Router::new(), } }; - let output_fields = named_fields.named; - let output = quote! { - #visibility struct #router_identifier { - #output_fields - router: ::windmark::router::Router, - } - impl #router_identifier { - #new_methods + fields + .named + .push(syn::parse_quote!(router: ::windmark::router::Router)); + + let name = &item.ident; + let conditions = super::conditional_attributes(&item.attrs); + let (implementation_generics, type_generics, where_clause) = + item.generics.split_for_impl(); + + quote! { + #item + + #(#conditions)* + impl #implementation_generics #name #type_generics #where_clause { + fn _new() -> Self { #initialization } pub async fn run(&mut self) -> Result<(), Box> { self.router.run().await @@ -67,7 +110,6 @@ pub fn fields(arguments: TokenStream, item: syn::ItemStruct) -> TokenStream { &mut self.router } } - }; - - output.into() + } + .into() } diff --git a/rossweisse/src/implementations/router/methods.rs b/rossweisse/src/implementations/router/methods.rs index 89aca3b..3e16e96 100644 --- a/rossweisse/src/implementations/router/methods.rs +++ b/rossweisse/src/implementations/router/methods.rs @@ -1,45 +1,66 @@ use proc_macro::TokenStream; +use quote::quote; pub fn methods(_arguments: TokenStream, item: &syn::ItemImpl) -> TokenStream { - let (routes, route_paths): (Vec<_>, Vec<_>) = item - .items - .iter() - .filter_map(|item| { - let syn::ImplItem::Fn(method) = item else { - return None; - }; - let route_attribute = method - .attrs - .iter() - .find(|attribute| attribute.path().is_ident("route"))?; - let is_index = route_attribute - .parse_args::() - .is_ok_and(|argument| argument == "index"); - let path = if is_index { - "/".to_owned() - } else { - format!("/{}", method.sig.ident) - }; - - Some((&method.sig.ident, path)) - }) - .unzip(); - let (implementation_generics, type_generics, where_clause) = + let mut mounts = Vec::new(); + + for member in &item.items { + let syn::ImplItem::Fn(method) = member else { + continue; + }; + let name = &method.sig.ident; + + if ["new", "_new", "run", "router"] + .iter() + .any(|reserved| name == reserved) + { + return syn::Error::new_spanned( + name, + "this method name is reserved by Rossweisse", + ) + .to_compile_error() + .into(); + } + + let Some(route_attribute) = method.attrs.iter().find(|attribute| { + attribute + .path() + .segments + .last() + .is_some_and(|segment| segment.ident == "route") + }) else { + continue; + }; + let is_index = route_attribute + .parse_args::() + .is_ok_and(|argument| argument == "index"); + let path = if is_index { + "/".to_owned() + } else { + format!("/{name}") + }; + let conditions = super::conditional_attributes(&method.attrs); + + mounts.push(quote! { + #(#conditions)* + router.router.mount(#path, |context| Self::#name(context)); + }); + } + + let (implementation_generics, _, where_clause) = item.generics.split_for_impl(); let name = &item.self_ty; + let conditions = super::conditional_attributes(&item.attrs); - quote::quote! { + quote! { #item - impl #implementation_generics #name #type_generics #where_clause { + #(#conditions)* + impl #implementation_generics #name #where_clause { pub fn new() -> Self { let mut router = Self::_new(); - #( - router.router.mount(#route_paths, |context| { - Self::#routes(context) - }); - )* + #(#mounts)* router } diff --git a/rossweisse/src/lib.rs b/rossweisse/src/lib.rs index a56c1af..b446d07 100644 --- a/rossweisse/src/lib.rs +++ b/rossweisse/src/lib.rs @@ -17,6 +17,11 @@ use syn::Item; /// Mark a `struct` as a router or an `impl` block as a router implementation. /// +/// Struct attributes and generics are preserved. Each field initialiser must +/// name a declared field, and repeated names are rejected. The `router` field +/// and the `new`, `_new`, `run`, and `router` methods are reserved for +/// generated code. +/// /// # Examples /// /// ```rust diff --git a/tests/consumer.rs b/tests/consumer.rs index 89cdc1d..70b1b5b 100644 --- a/tests/consumer.rs +++ b/tests/consumer.rs @@ -41,6 +41,23 @@ fn consumer_api_and_macro_forms_match_expected_behavior() { "mime", ), ("index_name", "", "mime"), + ("router_declarations", "", "mime"), + ( + "invalid_router_duplicate", + "duplicate field initialiser", + "mime", + ), + ( + "invalid_router_unknown", + "unknown field initialiser", + "mime", + ), + ("invalid_router_reserved", "reserved by Rossweisse", "mime"), + ( + "invalid_route_argument", + "only supported route argument", + "mime", + ), ]; for (name, diagnostic, extra_features) in cases { diff --git a/tests/consumer/src/bin/invalid_route_argument.rs b/tests/consumer/src/bin/invalid_route_argument.rs new file mode 100644 index 0000000..6b31b20 --- /dev/null +++ b/tests/consumer/src/bin/invalid_route_argument.rs @@ -0,0 +1,4 @@ +#[rossweisse::route(unknown)] +fn route() {} + +fn main() {} diff --git a/tests/consumer/src/bin/invalid_router_duplicate.rs b/tests/consumer/src/bin/invalid_router_duplicate.rs new file mode 100644 index 0000000..69067cd --- /dev/null +++ b/tests/consumer/src/bin/invalid_router_duplicate.rs @@ -0,0 +1,6 @@ +#[rossweisse::router(value = 1, value = 2)] +struct Capsule { + value: usize, +} + +fn main() {} diff --git a/tests/consumer/src/bin/invalid_router_reserved.rs b/tests/consumer/src/bin/invalid_router_reserved.rs new file mode 100644 index 0000000..b7507f5 --- /dev/null +++ b/tests/consumer/src/bin/invalid_router_reserved.rs @@ -0,0 +1,6 @@ +#[rossweisse::router] +struct Capsule { + router: usize, +} + +fn main() {} diff --git a/tests/consumer/src/bin/invalid_router_unknown.rs b/tests/consumer/src/bin/invalid_router_unknown.rs new file mode 100644 index 0000000..ed982f8 --- /dev/null +++ b/tests/consumer/src/bin/invalid_router_unknown.rs @@ -0,0 +1,4 @@ +#[rossweisse::router(missing = 1)] +struct Capsule; + +fn main() {} diff --git a/tests/consumer/src/bin/router_declarations.rs b/tests/consumer/src/bin/router_declarations.rs new file mode 100644 index 0000000..cfc7928 --- /dev/null +++ b/tests/consumer/src/bin/router_declarations.rs @@ -0,0 +1,39 @@ +use std::marker::PhantomData; +use windmark::{context::RouteContext, response::Response}; + +#[rossweisse::router(value = T::default())] +#[derive(Clone)] +struct Capsule<'a, T: Default + Clone, const SIZE: usize = 2> { + value: T, + label: &'a str, + marker: PhantomData<[(); SIZE]>, + #[cfg(any())] + absent: Missing, + #[cfg_attr(all(), cfg(any()))] + also_absent: Missing, +} + +#[rossweisse::router] +impl<'a, T: Default + Clone, const SIZE: usize> Capsule<'a, T, SIZE> { + #[rossweisse::route(index)] + fn index(_: RouteContext) -> Response { Response::success("index") } + + #[cfg_attr(all(), cfg(any()))] + #[rossweisse::route] + fn absent(_: Missing) -> Missing { unreachable!() } +} + +#[rossweisse::router] +#[cfg_attr(all(), cfg(any()), derive(Clone))] +struct Absent; + +#[rossweisse::router] +#[cfg(any())] +impl Absent {} + +fn main() { + let capsule = Capsule::::new().clone(); + + assert_eq!(capsule.value, 0); + assert_eq!(capsule.label, ""); +} diff --git a/tests/consumer/src/bin/valid.rs b/tests/consumer/src/bin/valid.rs index 5ffcfe6..0cf4666 100644 --- a/tests/consumer/src/bin/valid.rs +++ b/tests/consumer/src/bin/valid.rs @@ -21,7 +21,7 @@ struct Empty; #[rossweisse::router] impl Empty {} -#[rossweisse::router(count = 7, count = 8, ignored = 9)] +#[rossweisse::router(count = 7)] struct Capsule { count: usize, label: String, diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs index aa3f718..8a96e16 100644 --- a/tests/router_runtime.rs +++ b/tests/router_runtime.rs @@ -637,7 +637,7 @@ mod named_routes { #[rossweisse::router] impl Capsule { - #[route(index)] + #[rossweisse::route(index)] pub fn index(_: RouteContext) -> Response { Response::success("INDEX") } #[route] From 25e8c256ef2c650efb9c92c5bb4f17f009def579 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 09:34:27 +0000 Subject: [PATCH 26/40] fix(response)!: Normalise text line endings and terminate footers --- src/response.rs | 48 +++++++++++++++++++++++++++++++++++----- src/router.rs | 2 +- tests/binary_response.rs | 17 +++++++++++++- tests/router.rs | 2 +- tests/router_runtime.rs | 6 ++--- 5 files changed, 63 insertions(+), 12 deletions(-) diff --git a/src/response.rs b/src/response.rs index c0b7f95..cc885da 100644 --- a/src/response.rs +++ b/src/response.rs @@ -13,6 +13,9 @@ macro_rules! response { } /// A response holds the status and content returned by a handler. +/// +/// Text bodies use LF line endings, including a final newline after a +/// non-empty footer. Binary bodies are sent unchanged. #[derive(Clone)] #[non_exhaustive] pub struct Response { @@ -112,16 +115,16 @@ impl Response { return self.binary_content.unwrap_or_default(); } - let mut body = Vec::with_capacity(self.body_length(header, footer)); + let mut body = Vec::with_capacity(self.body_capacity(header, footer)); self.append_body(&mut body, header, footer); body } - pub(crate) fn body_length(&self, header: &str, footer: &str) -> usize { + pub(crate) fn body_capacity(&self, header: &str, footer: &str) -> usize { match self.status { - 20 => header.len() + self.content.len() + footer.len() + 1, + 20 => header.len() + self.content.len() + footer.len() + 2, 21 | 22 => self.binary_content.as_ref().map_or(0, Vec::len), _ => 0, } @@ -135,10 +138,31 @@ impl Response { ) { match self.status { 20 => { - body.extend_from_slice(header.as_bytes()); - body.extend_from_slice(self.content.as_bytes()); + let text = self.mime.as_deref().is_none_or(|mime| { + mime + .split_once('/') + .is_some_and(|(kind, _)| kind.eq_ignore_ascii_case("text")) + }); + + if text { + append_text(body, header); + append_text(body, &self.content); + } else { + body.extend_from_slice(header.as_bytes()); + body.extend_from_slice(self.content.as_bytes()); + } + body.push(b'\n'); - body.extend_from_slice(footer.as_bytes()); + + if text { + append_text(body, footer); + + if body.last() != Some(&b'\n') { + body.push(b'\n'); + } + } else { + body.extend_from_slice(footer.as_bytes()); + } } 21 | 22 => @@ -182,6 +206,18 @@ impl Response { } } +fn append_text(body: &mut Vec, mut text: &str) { + while let Some(position) = text.find('\r') { + body.extend_from_slice(&text.as_bytes()[..position]); + body.push(b'\n'); + + text = &text[position + 1..]; + text = text.strip_prefix('\n').unwrap_or(text); + } + + body.extend_from_slice(text.as_bytes()); +} + impl std::future::IntoFuture for Response { type IntoFuture = std::future::Ready; type Output = Self; diff --git a/src/router.rs b/src/router.rs index 49daa30..a5f68b4 100644 --- a/src/router.rs +++ b/src/router.rs @@ -320,7 +320,7 @@ fn serialize_response( footer: &str, ) -> Vec { let mut response = Vec::with_capacity( - status_line.len() + content.body_length(header, footer) + 2, + status_line.len() + content.body_capacity(header, footer) + 2, ); response.extend_from_slice(status_line.as_bytes()); diff --git a/tests/binary_response.rs b/tests/binary_response.rs index 7422074..e05caf9 100644 --- a/tests/binary_response.rs +++ b/tests/binary_response.rs @@ -42,7 +42,7 @@ fn serialize_body_text_success_wraps_with_header_and_footer() { let response = Response::success("body"); let body = response.serialize_body("HEADER\n", "FOOTER"); - assert_eq!(body, b"HEADER\nbody\nFOOTER"); + assert_eq!(body, b"HEADER\nbody\nFOOTER\n"); } #[test] @@ -65,3 +65,18 @@ fn public_binary_serialization_returns_the_existing_allocation() { assert_eq!(body, NON_UTF8); } } + +#[test] +fn text_serialization_normalizes_line_endings_and_terminates_the_footer() { + let response = Response::success("one\r\ntwo\rthree"); + + assert_eq!( + response.serialize_body("header\r\n", "footer"), + b"header\none\ntwo\nthree\nfooter\n" + ); + assert_eq!( + Response::binary_success(b"one\r\ntwo\r", "text/plain") + .serialize_body("", ""), + b"one\r\ntwo\r" + ); +} diff --git a/tests/router.rs b/tests/router.rs index fcfde15..1d8b33e 100644 --- a/tests/router.rs +++ b/tests/router.rs @@ -252,7 +252,7 @@ fn response_assembly_preserves_publicly_constructible_states() { ( 20, "20 text/gemini; charset=utf-8; lang=en", - b"HEADERbody\nFOOTER".as_slice(), + b"HEADERbody\nFOOTER\n".as_slice(), ), (21, "20 ", b"\x00\xff".as_slice()), (22, "20 ", b"\x00\xff".as_slice()), diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs index 8a96e16..e4f0e70 100644 --- a/tests/router_runtime.rs +++ b/tests/router_runtime.rs @@ -259,7 +259,7 @@ async fn callbacks_partials_and_case_folding_keep_their_existing_order() { request( address, "/Users/Alice", - b"20 text/gemini; charset=utf-8; lang=en\r\nHEADER\nAlice!\nFOOTER", + b"20 text/gemini; charset=utf-8; lang=en\r\nHEADER\nAlice!\nFOOTER\n", ); request( address, @@ -359,7 +359,7 @@ async fn cloned_routers_retain_partials_and_running_servers_keep_their_snapshot( serve_requests(router.clone(), move |address| { let expected = - b"20 text/gemini; charset=utf-8; lang=en\r\nHEADER\nBODY\nFOOTER"; + b"20 text/gemini; charset=utf-8; lang=en\r\nHEADER\nBODY\nFOOTER\n"; request(address, "/", expected); registration.add_header(|_: &windmark::context::RouteContext| { @@ -377,7 +377,7 @@ async fn cloned_routers_retain_partials_and_running_servers_keep_their_snapshot( request( address, "/", - b"20 text/gemini; charset=utf-8; lang=en\r\nHEADER\nSECOND HEADER\nBODY\nFOOTER\nSECOND FOOTER", + b"20 text/gemini; charset=utf-8; lang=en\r\nHEADER\nSECOND HEADER\nBODY\nFOOTER\nSECOND FOOTER\n", ); }) .await; From b344bcf564dc664d66ff5f5222a4edaeb2583c42 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 09:34:59 +0000 Subject: [PATCH 27/40] fix(router)!: Load complete certificate chains BREAKING CHANGE: File credential setters replace earlier inline values. Explicit custom TLS acceptors retain precedence over credentials. --- src/router.rs | 71 ++++++++++++++++++++++---------------------- tests/router.rs | 78 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 36 deletions(-) diff --git a/src/router.rs b/src/router.rs index a5f68b4..0566c22 100644 --- a/src/router.rs +++ b/src/router.rs @@ -88,8 +88,7 @@ pub struct Router { certificate_content: Option, headers: Arc>>>, footers: Arc>>>, - ssl_acceptor: Arc, - manual_acceptor_set: bool, + ssl_acceptor: Option>, #[cfg(feature = "logger")] default_logger: bool, #[cfg(feature = "logger")] @@ -472,14 +471,10 @@ impl Router { /// ```rust /// windmark::router::Router::new(); /// ``` - /// - /// # Panics - /// - /// if a default `SslAcceptor` could not be built. #[must_use] pub fn new() -> Self { Self::default() } - /// Set the filename of the private key file. + /// Set the filename of the private key file, replacing any inline key. /// /// # Examples /// @@ -491,6 +486,7 @@ impl Router { private_key_file_name: impl Into + AsRef, ) -> &mut Self { self.private_key_file_name = private_key_file_name.into(); + self.private_key_content = None; self } @@ -511,7 +507,8 @@ impl Router { self } - /// Set the filename of the certificate chain file. + /// Set the filename of the certificate chain file, replacing any inline + /// chain. /// /// # Examples /// @@ -523,11 +520,12 @@ impl Router { certificate_name: impl Into + AsRef, ) -> &mut Self { self.certificate_file_name = certificate_name.into(); + self.certificate_content = None; self } - /// Set the content of the certificate chain file. + /// Set a PEM certificate chain, with the server certificate first. /// /// # Examples /// @@ -666,15 +664,17 @@ impl Router { /// /// # Panics /// - /// if the client could not be accepted. + /// This method panics if a partial registry lock has been poisoned. /// /// # Errors /// - /// if the `TcpListener` could not be bound. + /// This method returns an error if TLS configuration or listener binding + /// fails. pub async fn run(&mut self) -> Result<(), Box> { - if !self.manual_acceptor_set { - self.create_acceptor()?; - } + let acceptor = match &self.ssl_acceptor { + Some(acceptor) => acceptor.clone(), + None => Arc::new(self.create_acceptor()?), + }; #[cfg(feature = "logger")] if self.default_logger { @@ -723,7 +723,6 @@ impl Router { module_scheduling: self.module_scheduling.clone(), options: self.options.clone(), }); - let acceptor = self.ssl_acceptor.clone(); // Under Tokio, Ctrl+C stops the server from accepting new connections // without waiting for active connection tasks to finish. The async-std @@ -759,18 +758,26 @@ impl Router { } } - fn create_acceptor(&mut self) -> Result<(), Box> { + fn create_acceptor(&self) -> Result> { let mut builder = SslAcceptor::mozilla_intermediate(SslMethod::tls())?; - if let Some(ref cert_content) = self.certificate_content { - builder.set_certificate( - openssl::x509::X509::from_pem(cert_content.as_bytes())?.as_ref(), - )?; + if let Some(ref content) = self.certificate_content { + let mut certificates = + openssl::x509::X509::stack_from_pem(content.as_bytes())?.into_iter(); + let leaf = certificates.next().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "the certificate chain is empty", + ) + })?; + + builder.set_certificate(&leaf)?; + + for certificate in certificates { + builder.add_extra_chain_cert(certificate)?; + } } else { - builder.set_certificate_file( - &self.certificate_file_name, - ssl::SslFiletype::PEM, - )?; + builder.set_certificate_chain_file(&self.certificate_file_name)?; } if let Some(ref key_content) = self.private_key_content { @@ -795,12 +802,10 @@ impl Router { .as_bytes(), )?; - self.ssl_acceptor = Arc::new(builder.build()); - - Ok(()) + Ok(builder.build()) } - /// Use a custom `SslAcceptor`. + /// Use a custom `SslAcceptor` instead of building one from the credentials. /// /// # Examples /// @@ -823,8 +828,7 @@ impl Router { /// }); /// ``` pub fn set_ssl_acceptor(&mut self, ssl_acceptor: SslAcceptor) -> &mut Self { - self.ssl_acceptor = Arc::new(ssl_acceptor); - self.manual_acceptor_set = true; + self.ssl_acceptor = Some(Arc::new(ssl_acceptor)); self } @@ -1294,12 +1298,7 @@ impl Default for Router { certificate_file_name: String::new(), headers: Arc::new(Mutex::new(vec![])), footers: Arc::new(Mutex::new(vec![])), - ssl_acceptor: Arc::new( - SslAcceptor::mozilla_intermediate(SslMethod::tls()) - .expect("failed to create default SSL acceptor") - .build(), - ), - manual_acceptor_set: false, + ssl_acceptor: None, #[cfg(feature = "logger")] default_logger: false, #[cfg(feature = "logger")] diff --git a/tests/router.rs b/tests/router.rs index 1d8b33e..88dd905 100644 --- a/tests/router.rs +++ b/tests/router.rs @@ -638,3 +638,81 @@ async fn exclusive_async_phases_wait_for_existing_concurrent_phases() { exclusive.await; } + +#[test] +fn credentials_load_complete_chains_and_use_the_last_setter() { + use openssl::{ + asn1::Asn1Time, + ec::{EcGroup, EcKey}, + hash::MessageDigest, + nid::Nid, + pkey::PKey, + x509::{X509NameBuilder, X509}, + }; + + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap(); + let key = PKey::from_ec_key(EcKey::generate(&group).unwrap()).unwrap(); + let mut name = X509NameBuilder::new().unwrap(); + + name.append_entry_by_text("CN", "localhost").unwrap(); + + let name = name.build(); + let mut certificate = X509::builder().unwrap(); + + certificate.set_version(2).unwrap(); + certificate.set_subject_name(&name).unwrap(); + certificate.set_issuer_name(&name).unwrap(); + certificate.set_pubkey(&key).unwrap(); + certificate + .set_not_before(&Asn1Time::days_from_now(0).unwrap()) + .unwrap(); + certificate + .set_not_after(&Asn1Time::days_from_now(1).unwrap()) + .unwrap(); + certificate.sign(&key, MessageDigest::sha256()).unwrap(); + + let certificate = certificate.build(); + let certificate_pem = + String::from_utf8(certificate.to_pem().unwrap()).unwrap(); + let key_pem = + String::from_utf8(key.private_key_to_pem_pkcs8().unwrap()).unwrap(); + let chain = certificate_pem.repeat(2); + let directory = std::env::temp_dir().join(format!( + "windmark-credentials-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + )); + + std::fs::create_dir(&directory).unwrap(); + std::fs::write(directory.join("chain.pem"), &chain).unwrap(); + std::fs::write(directory.join("key.pem"), &key_pem).unwrap(); + + let mut router = super::Router::new(); + + router.set_certificate("invalid"); + router.set_private_key("invalid"); + router.set_certificate_file(directory.join("chain.pem").to_str().unwrap()); + router.set_private_key_file(directory.join("key.pem").to_str().unwrap()); + + let acceptor = router.create_acceptor().unwrap(); + + assert_eq!(acceptor.context().extra_chain_certs().len(), 1); + router.set_certificate_file("missing-certificate.pem"); + router.set_private_key_file("missing-key.pem"); + router.set_certificate(chain); + router.set_private_key(key_pem); + + let acceptor = router.create_acceptor().unwrap(); + + assert_eq!(acceptor.context().extra_chain_certs().len(), 1); + assert_eq!( + acceptor.context().certificate().unwrap().to_der().unwrap(), + certificate.to_der().unwrap(), + ); + router.set_certificate(""); + assert!(router.create_acceptor().is_err()); + std::fs::remove_dir_all(directory).unwrap(); +} From b87a0b7314ca1363832039c15d18bf7c87e1c32a Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 09:34:59 +0000 Subject: [PATCH 28/40] fix(logging): Preserve an existing application logger --- src/router.rs | 4 ++-- tests/router_runtime.rs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/router.rs b/src/router.rs index 0566c22..b516d29 100644 --- a/src/router.rs +++ b/src/router.rs @@ -678,9 +678,9 @@ impl Router { #[cfg(feature = "logger")] if self.default_logger { - pretty_env_logger::formatted_builder() + let _ = pretty_env_logger::formatted_builder() .parse_filters(&self.log_filter) - .init(); + .try_init(); } #[cfg(feature = "tokio")] diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs index e4f0e70..026edb5 100644 --- a/tests/router_runtime.rs +++ b/tests/router_runtime.rs @@ -674,3 +674,40 @@ async fn index_route_attributes_preserve_names_and_select_the_root_path() { }) .await; } + +#[cfg(feature = "logger")] +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn startup_preserves_an_existing_logger() { + struct Logger; + + impl log::Log for Logger { + fn enabled(&self, _: &log::Metadata<'_>) -> bool { false } + + fn log(&self, _: &log::Record<'_>) {} + + fn flush(&self) {} + } + + static LOGGER: Logger = Logger; + + log::set_logger(&LOGGER).unwrap(); + + for _ in 0..2 { + let mut router = Router::new(); + + router.enable_default_logger(true); + router.mount("/", |_| Response::success("ready")); + serve_requests(router, |address| { + request( + address, + "/", + b"20 text/gemini; charset=utf-8; lang=en\r\nready\n", + ); + }) + .await; + } +} From 3193d428daaf73148001b7ec46296ff2959064aa Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 09:36:13 +0000 Subject: [PATCH 29/40] fix(protocol)!: Validate Gemini requests and response headers BREAKING CHANGE: Invalid response statuses or metadata produce a temporary failure. Multiple language tags are quoted, and empty errors omit the space. --- Cargo.toml | 2 + src/response.rs | 5 +- src/router.rs | 227 ++++++++++++++++++++++++++++------------ src/router/request.rs | 80 ++++++++++++++ tests/router.rs | 110 +++++++++++++++++-- tests/router_runtime.rs | 56 ++++++++-- 6 files changed, 391 insertions(+), 89 deletions(-) create mode 100644 src/router/request.rs diff --git a/Cargo.toml b/Cargo.toml index a3a31aa..4a58487 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,8 @@ log = "0.4.30" # URL url = "2.2.2" matchit = "0.7.3" +mime = "0.3.17" +language-tags = "0.3.2" tree_magic_mini = { version = "3.2.2", optional = true } # MIME diff --git a/src/response.rs b/src/response.rs index cc885da..f651cf1 100644 --- a/src/response.rs +++ b/src/response.rs @@ -14,8 +14,9 @@ macro_rules! response { /// A response holds the status and content returned by a handler. /// -/// Text bodies use LF line endings, including a final newline after a -/// non-empty footer. Binary bodies are sent unchanged. +/// The server replaces responses whose status or metadata cannot be encoded +/// with a temporary failure. Text bodies use LF line endings, including a +/// final newline after a non-empty footer. Binary bodies are sent unchanged. #[derive(Clone)] #[non_exhaustive] pub struct Response { diff --git a/src/router.rs b/src/router.rs index b516d29..fd91eac 100644 --- a/src/router.rs +++ b/src/router.rs @@ -31,26 +31,13 @@ use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, sync::{Mutex as AsyncMutex, RwLock as AsyncRwLock}, }; -use url::Url; +mod request; mod routes; use routes::Routes; -macro_rules! or_error { - ($stream:ident, $operation:expr, $error_format:literal) => { - match $operation { - Ok(u) => u, - Err(e) => { - $stream - .write_all(format!($error_format, e).as_bytes()) - .await?; - - return Ok(()); - } - } - }; -} +const MAX_REQUEST_URI_BYTES: usize = 1024; #[cfg(feature = "tokio")] type TcpStream = tokio::net::TcpStream; @@ -78,6 +65,10 @@ enum HookPhase { } /// A router dispatches requests through its handlers, partials, and modules. +/// +/// Requests must contain an absolute URI of at most 1024 bytes, excluding +/// CRLF, without userinfo or a fragment. Non-ASCII characters must be +/// percent-encoded. Other schemes remain available to proxy handlers. #[derive(Clone)] pub struct Router { routes: Routes, @@ -127,44 +118,60 @@ impl RequestHandler { clippy::cognitive_complexity )] async fn handle(&self, stream: &mut Stream) -> Result<(), Box> { - let mut buffer = [0u8; 1024]; + let mut buffer = [0u8; MAX_REQUEST_URI_BYTES]; let mut footer = String::new(); let mut header = String::new(); let mut request = Vec::new(); - let mut url = loop { - let size = match stream.read(&mut buffer).await { - Ok(0) | Err(_) => return Ok(()), - Ok(size) => size, - }; - - request.extend_from_slice(&buffer[..size]); + let request = async { + let url = loop { + let size = match stream.read(&mut buffer).await { + Ok(0) => return Ok(None), + Err(error) => return Err(error), + Ok(size) => size, + }; + + request.extend_from_slice(&buffer[..size]); + + let request_end = request.windows(2).position(|pair| pair == b"\r\n"); + let uri_length = request_end.unwrap_or_else(|| { + request.len() - usize::from(request.last() == Some(&b'\r')) + }); + + if uri_length > MAX_REQUEST_URI_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "request URI exceeds 1024 bytes", + )); + } - if request.len() > 1024 { - stream - .write_all( - b"59 The server (Windmark) received a request exceeding 1024 \ - bytes\r\n", - ) - .await?; + if let Some(position) = request_end { + let request = + std::str::from_utf8(&request[..position]).map_err(|error| { + std::io::Error::new(std::io::ErrorKind::InvalidData, error) + })?; - return Ok(()); - } + break request::parse_uri(request).map_err(|error| { + std::io::Error::new(std::io::ErrorKind::InvalidData, error) + })?; + } + }; - if let Some(position) = - request.windows(2).position(|pair| pair == b"\r\n") - { - let request = or_error!( - stream, - std::str::from_utf8(&request[..position]), - "59 The server (Windmark) received a bad request: {}\r\n" + Ok(Some(url)) + } + .await; + let mut url = match request { + Ok(Some(url)) => url, + Ok(None) => return Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::InvalidData => { + let response = format!( + "59 The server (Windmark) received a bad request: {error}\r\n" ); - break or_error!( - stream, - Url::parse(request), - "59 The server (Windmark) received a bad request: {}\r\n" - ); + stream.write_all(response.as_bytes()).await?; + + return Ok(()); } + Err(error) => return Err(error.into()), }; if url.path().is_empty() { @@ -235,15 +242,26 @@ impl RequestHandler { }); self.post_route_callback.call(&hook_context, &mut content); - let status_line = - status_line(&content, &self.character_set, &self.languages_joined); + let status_line = match status_line( + &content, + &self.character_set, + &self.languages_joined, + ) { + Ok(line) => line, + Err(error) => { + error!("response encoding error: {error}"); + + content = Response::temporary_failure( + "The server could not encode the response", + ); + + "40 The server could not encode the response".to_owned() + } + }; let response = serialize_response(content, &status_line, &header, &footer); stream.write_all(&response).await?; - #[cfg(feature = "tokio")] - stream.shutdown().await?; - #[cfg(feature = "async-std")] - stream.get_mut().shutdown(std::net::Shutdown::Both)?; + Ok(()) } } @@ -383,6 +401,22 @@ fn spawn_connection( if let Err(e) = handler.handle(&mut stream).await { error!("handle error: {e}"); + + return; + } + + #[cfg(feature = "tokio")] + let shutdown = stream.shutdown(); + #[cfg(feature = "async-std")] + let shutdown = std::future::poll_fn(|context| { + async_std::io::Write::poll_close( + std::pin::Pin::new(&mut stream), + context, + ) + }); + + if let Err(error) = shutdown.await { + error!("TLS shutdown error: {error}"); } } Err(e) => error!("ssl stream error: {e:?}"), @@ -390,37 +424,92 @@ fn spawn_connection( }); } -/// Build the Gemini response status line for `content`, falling back to the -/// router's `default_character_set` and `default_languages` for `20` responses. -/// -/// Statuses `21`/`22` (binary success) are sent to clients as a plain `20` with -/// the response's MIME as the meta; every other status uses the first line of -/// its `content` as the meta. +/// Build a protocol-valid response header from the handler's response. fn status_line( content: &Response, default_character_set: &str, default_languages: &str, -) -> String { - match content.status { - 20 => { - let mime = content.mime.as_deref().unwrap_or("text/gemini"); +) -> Result { + let status = content.status; + + if matches!(status, 20..=22) { + let mime = content.mime.as_deref().unwrap_or(if status == 20 { + "text/gemini" + } else { + "application/octet-stream" + }); + let metadata = if status == 20 { let character_set = content .character_set .as_deref() .unwrap_or(default_character_set); let languages = content.languages.as_ref().map_or_else( - || std::borrow::Cow::Borrowed(default_languages), - |languages| std::borrow::Cow::Owned(languages.join(",")), + || default_languages.to_owned(), + |languages| languages.join(","), ); + let mut metadata = + format!("{mime}; charset={}", mime_parameter(character_set)); + + if !languages.is_empty() { + if languages + .split(',') + .any(|language| language_tags::LanguageTag::parse(language).is_err()) + { + return Err("invalid response language tag"); + } + + write!(&mut metadata, "; lang={}", mime_parameter(&languages)) + .expect("writing to a string cannot fail"); + } + + metadata + } else { + mime.to_owned() + }; - format!("20 {mime}; charset={character_set}; lang={languages}") + if metadata.chars().any(char::is_control) + || metadata.parse::().is_err() + { + return Err("invalid response media type or parameters"); } - 21 | 22 => format!("20 {}", content.mime.as_deref().unwrap_or_default()), - status => - format!( - "{status} {}", - content.content.lines().next().unwrap_or_default() - ), + + return Ok(format!("20 {metadata}")); + } + + if !matches!(status, 10 | 11 | 30 | 31 | 40..=44 | 50..=53 | 59..=62) { + return Err("undefined response status"); + } + + let metadata = content.content.lines().next().unwrap_or_default(); + + if metadata.chars().any(char::is_control) { + return Err("response metadata contains a control character"); + } + + if matches!(status, 10 | 11 | 30 | 31) && metadata.is_empty() { + return Err("input prompts and redirect targets must not be empty"); + } + + if matches!(status, 30 | 31) && !request::valid_uri_reference(metadata) { + return Err("invalid redirect URI reference"); + } + + if metadata.is_empty() { + Ok(status.to_string()) + } else { + Ok(format!("{status} {metadata}")) + } +} + +fn mime_parameter(value: &str) -> String { + if !value.is_empty() + && value.bytes().all(|byte| { + byte.is_ascii_graphic() && !b"()<>@,;:\\\"/[]?=".contains(&byte) + }) + { + value.to_owned() + } else { + format!("\"{}\"", value.replace('\\', "\\\\").replace('\"', "\\\"")) } } diff --git a/src/router/request.rs b/src/router/request.rs new file mode 100644 index 0000000..175f915 --- /dev/null +++ b/src/router/request.rs @@ -0,0 +1,80 @@ +use url::Url; + +pub(super) fn parse_uri(request: &str) -> Result { + if !valid_uri_characters(request, false) { + return Err("request URI contains invalid syntax".into()); + } + + if let Some((_, remainder)) = request.split_once(':') { + let path_and_query = if let Some(authority) = remainder.strip_prefix("//") { + let end = authority.find(['/', '?']).unwrap_or(authority.len()); + + if authority[..end].contains('@') { + return Err("request URI must not contain userinfo".into()); + } + + &authority[end..] + } else { + remainder + }; + + if path_and_query.contains(['[', ']']) { + return Err("request URI contains an unescaped bracket".into()); + } + } + + let url = Url::parse(request).map_err(|error| error.to_string())?; + + if url.scheme() == "gemini" && url.host_str().is_none_or(str::is_empty) { + return Err("Gemini request URI requires a host".into()); + } + + Ok(url) +} + +fn valid_uri_characters(request: &str, allow_fragment: bool) -> bool { + let bytes = request.as_bytes(); + let mut position = 0; + + while position < bytes.len() { + let byte = bytes[position]; + + if byte == b'%' { + if bytes + .get(position + 1..position + 3) + .is_none_or(|digits| !digits.iter().all(u8::is_ascii_hexdigit)) + { + return false; + } + + position += 3; + } else if byte.is_ascii_alphanumeric() + || b":/?[]@!$&'()*+,;=-._~".contains(&byte) + || (allow_fragment && byte == b'#') + { + position += 1; + } else { + return false; + } + } + + true +} + +pub(super) fn valid_uri_reference(reference: &str) -> bool { + let remainder = reference + .split_once(':') + .filter(|(prefix, _)| !prefix.contains(['/', '?', '#'])) + .map_or(reference, |(_, remainder)| remainder); + let path = remainder.strip_prefix("//").map_or(remainder, |authority| { + &authority[authority.find(['/', '?', '#']).unwrap_or(authority.len())..] + }); + + valid_uri_characters(reference, true) + && !path.contains(['[', ']']) + && reference.matches('#').count() <= 1 + && Url::parse("gemini://localhost/") + .expect("the base URI is valid") + .join(reference) + .is_ok() +} diff --git a/tests/router.rs b/tests/router.rs index 88dd905..955f65b 100644 --- a/tests/router.rs +++ b/tests/router.rs @@ -125,7 +125,9 @@ fn case_insensitive_lookup_combines_with_trailing_slash_removal() { /// Format `response`'s status line with the router's default charset and /// language (matching `Router::default`). -fn line(response: &Response) -> String { status_line(response, "utf-8", "en") } +fn line(response: &Response) -> String { + status_line(response, "utf-8", "en").unwrap() +} #[test] fn success_status_line_falls_back_to_router_defaults() { @@ -159,7 +161,10 @@ fn success_status_line_joins_the_response_languages() { let mut response = Response::success("hi"); response.with_languages(["en", "fr"]); - assert_eq!(line(&response), "20 text/gemini; charset=utf-8; lang=en,fr"); + assert_eq!( + line(&response), + "20 text/gemini; charset=utf-8; lang=\"en,fr\"" + ); } #[test] @@ -171,8 +176,8 @@ fn binary_success_is_sent_as_a_plain_success_with_its_mime() { } #[test] -fn status_21_without_a_mime_emits_an_empty_meta() { - assert_eq!(line(&Response::new(21, "")), "20 "); +fn status_21_without_a_mime_uses_binary_media_type() { + assert_eq!(line(&Response::new(21, "")), "20 application/octet-stream"); } #[test] @@ -254,12 +259,9 @@ fn response_assembly_preserves_publicly_constructible_states() { "20 text/gemini; charset=utf-8; lang=en", b"HEADERbody\nFOOTER\n".as_slice(), ), - (21, "20 ", b"\x00\xff".as_slice()), - (22, "20 ", b"\x00\xff".as_slice()), - (23, "23 body", b"".as_slice()), + (21, "20 application/octet-stream", b"\x00\xff".as_slice()), + (22, "20 application/octet-stream", b"\x00\xff".as_slice()), (40, "40 body", b"".as_slice()), - (79, "79 body", b"".as_slice()), - (-1, "-1 body", b"".as_slice()), ] { let mut response = Response::new(status, "body"); @@ -287,8 +289,13 @@ fn response_assembly_preserves_publicly_constructible_states() { .serialize_body("HEADER", "FOOTER") .is_empty()); assert_eq!( - super::serialize_response(response, "20 ", "HEADER", "FOOTER"), - b"20 \r\n" + super::serialize_response( + response, + "20 application/octet-stream", + "HEADER", + "FOOTER" + ), + b"20 application/octet-stream\r\n" ); } } @@ -639,6 +646,87 @@ async fn exclusive_async_phases_wait_for_existing_concurrent_phases() { exclusive.await; } +#[test] +fn request_uris_reject_forbidden_syntax_before_normalization() { + for request in [ + "gemini://user@localhost/", + "gemini://@localhost/", + "gemini://localhost/#", + "gemini://localhost/#fragment", + "gemini://localhost/\t", + "gemini://localhost/?hello world", + "gemini://localhost/?%ZZ", + "gemini://localhost/?%0", + "gemini://localhost/?%", + "gemini://localhost/[path]", + "gemini://localhost/café", + "gemini:///", + "/relative", + ] { + assert!(super::request::parse_uri(request).is_err(), "{request}"); + } + + for request in [ + "gemini://localhost/", + "gemini://localhost", + "gemini://[::1]/", + "gemini://localhost/?hello%20world", + "gemini://localhost/caf%C3%A9", + "gemini://localhost/%23%40%5B", + "gopher://localhost/1/example", + "https://localhost/", + "mailto:user@example.com", + ] { + assert!(super::request::parse_uri(request).is_ok(), "{request}"); + } +} + +#[test] +fn response_headers_reject_invalid_statuses_and_metadata() { + for response in [ + Response::new(-1, "message"), + Response::new(23, "message"), + Response::new(99, "message"), + Response::input(""), + Response::temporary_redirect(""), + Response::temporary_redirect("/hello world"), + Response::temporary_redirect("/%XX"), + Response::temporary_redirect("/[path]"), + Response::temporary_redirect("/#one#two"), + Response::bad_request("one\rtwo"), + Response::bad_request("one\u{0085}two"), + Response::binary_success([], "text/plain\r\ninjected"), + Response::binary_success([], "invalid media type"), + ] { + assert!(status_line(&response, "utf-8", "en").is_err()); + } + + assert_eq!(line(&Response::temporary_failure("")), "40"); + assert_eq!( + line(&Response::temporary_redirect("../page#part")), + "30 ../page#part" + ); + assert_eq!( + line(&Response::temporary_redirect("https://example.com/")), + "30 https://example.com/" + ); +} + +#[test] +fn response_languages_support_private_tags_and_omission() { + let mut response = Response::success("hello"); + + response.with_languages(["x-private", "zh-Hans-CN"]); + assert_eq!( + line(&response), + "20 text/gemini; charset=utf-8; lang=\"x-private,zh-Hans-CN\"" + ); + response.with_languages(Vec::::new()); + assert_eq!(line(&response), "20 text/gemini; charset=utf-8"); + response.with_languages(["en;injected=value"]); + assert!(status_line(&response, "utf-8", "en").is_err()); +} + #[test] fn credentials_load_complete_chains_and_use_the_last_setter() { use openssl::{ diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs index 026edb5..99c1ebc 100644 --- a/tests/router_runtime.rs +++ b/tests/router_runtime.rs @@ -114,7 +114,7 @@ fn request(address: SocketAddr, path: &str, expected: &[u8]) { ); } -fn connect(address: SocketAddr) -> openssl::ssl::SslStream { +fn connect_tcp(address: SocketAddr) -> TcpStream { let started = Instant::now(); let stream = loop { match TcpStream::connect_timeout(&address, Duration::from_millis(100)) { @@ -134,6 +134,11 @@ fn connect(address: SocketAddr) -> openssl::ssl::SslStream { .set_write_timeout(Some(Duration::from_secs(5))) .unwrap(); + stream +} + +fn connect(address: SocketAddr) -> openssl::ssl::SslStream { + let stream = connect_tcp(address); let mut connector = SslConnector::builder(SslMethod::tls()).unwrap(); connector.set_verify(SslVerifyMode::NONE); @@ -157,6 +162,10 @@ fn exchange( stream.read_exact(&mut output).unwrap(); assert_eq!(output, expected); + assert_eq!(stream.read(&mut [0]).unwrap(), 0); + assert!(stream + .get_shutdown() + .contains(openssl::ssl::ShutdownState::RECEIVED)); } async fn serve_requests( @@ -308,7 +317,7 @@ async fn request_framing_handles_split_characters_and_terminates_errors() { serve_requests(router, |address| { let success = b"20 text/gemini; charset=utf-8; lang=en\r\naccepted\n"; let invalid_utf8 = b"gemini://localhost/\xff"; - let invalid_url = "not a url"; + let invalid_url = "relative"; let utf8_error = format!( "59 The server (Windmark) received a bad request: {}\r\n", std::str::from_utf8(invalid_utf8.as_slice()).unwrap_err() @@ -318,12 +327,12 @@ async fn request_framing_handles_split_characters_and_terminates_errors() { url::Url::parse(invalid_url).unwrap_err() ); let prefix = "gemini://localhost/"; - let maximum_url = format!("{prefix}{}", "a".repeat(1022 - prefix.len())); + let maximum_url = format!("{prefix}{}", "a".repeat(1024 - prefix.len())); - assert_eq!(maximum_url.len(), 1022); + assert_eq!(maximum_url.len(), 1024); request_chunks( address, - &[b"gemini://localhost/caf\xc3", b"\xa9\r", b"\n"], + &[b"gemini://localhost/caf%C", b"3%A9\r", b"\n"], success, ); request_chunks(address, &[invalid_utf8, b"\r\n"], utf8_error.as_bytes()); @@ -332,11 +341,16 @@ async fn request_framing_handles_split_characters_and_terminates_errors() { &[invalid_url.as_bytes(), b"\r\n"], url_error.as_bytes(), ); - request_chunks(address, &[maximum_url.as_bytes(), b"\r\n"], success); + request_chunks(address, &[maximum_url.as_bytes(), b"\r", b"\n"], success); + + let mut followed_by_extra_bytes = b"gemini://localhost/\r\n".to_vec(); + + followed_by_extra_bytes.extend_from_slice(&[b'x'; 1024]); + request_chunks(address, &[&followed_by_extra_bytes], success); request_chunks( address, &[maximum_url.as_bytes(), b"a\r\n"], - b"59 The server (Windmark) received a request exceeding 1024 bytes\r\n", + b"59 The server (Windmark) received a bad request: request URI exceeds 1024 bytes\r\n", ); }) .await; @@ -675,6 +689,34 @@ async fn index_route_attributes_preserve_names_and_select_the_root_path() { .await; } +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn invalid_response_headers_become_complete_failure_responses() { + let mut router = Router::new(); + + router.mount("/status", |_| Response::new(99, "invalid")); + router.mount("/metadata", |_| { + let mut response = Response::success("must not escape"); + + response.with_mime("text/gemini\r\nINJECTED"); + + response + }); + serve_requests(router, |address| { + for path in ["/status", "/metadata"] { + request( + address, + path, + b"40 The server could not encode the response\r\n", + ); + } + }) + .await; +} + #[cfg(feature = "logger")] #[cfg_attr( feature = "tokio", From 5150a45ecd0766dd097861aeb735e83093de4ae0 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 09:36:41 +0000 Subject: [PATCH 30/40] feat(router)!: Add connection limits and bounded shutdown BREAKING CHANGE: Stopping or dropping the server cancels unfinished connection tasks after the configured drain period, which defaults to zero. Admission limits and stage deadlines remain opt-in. --- Cargo.toml | 2 + src/router.rs | 171 +++++++++++--------------- src/router/connections.rs | 207 +++++++++++++++++++++++++++++++ tests/router_runtime.rs | 249 +++++++++++++++++++++++++++++++++++++- 4 files changed, 530 insertions(+), 99 deletions(-) create mode 100644 src/router/connections.rs diff --git a/Cargo.toml b/Cargo.toml index 4a58487..e9a8c6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,8 +40,10 @@ tokio = { version = "1.52.3", default-features = false, features = [ "io-util", "macros", "signal", + "time", ], optional = true } async-trait = "0.1.68" +futures-util = "0.3.31" async-std = { version = "1.12.0", features = ["attributes"], optional = true } # Logging diff --git a/src/router.rs b/src/router.rs index fd91eac..a26123a 100644 --- a/src/router.rs +++ b/src/router.rs @@ -32,9 +32,12 @@ use tokio::{ sync::{Mutex as AsyncMutex, RwLock as AsyncRwLock}, }; +mod connections; mod request; mod routes; +use connections::with_timeout; +pub use connections::ConnectionLimits; use routes::Routes; const MAX_REQUEST_URI_BYTES: usize = 1024; @@ -94,6 +97,7 @@ pub struct Router { module_scheduling: Arc, options: HashSet, listener_address: String, + connection_limits: ConnectionLimits, } struct RequestHandler { @@ -109,6 +113,7 @@ struct RequestHandler { modules: Arc>>, module_scheduling: Arc, options: HashSet, + connection_limits: ConnectionLimits, } impl RequestHandler { @@ -122,7 +127,7 @@ impl RequestHandler { let mut footer = String::new(); let mut header = String::new(); let mut request = Vec::new(); - let request = async { + let request = with_timeout(self.connection_limits.request_timeout, async { let url = loop { let size = match stream.read(&mut buffer).await { Ok(0) => return Ok(None), @@ -157,8 +162,8 @@ impl RequestHandler { }; Ok(Some(url)) - } - .await; + }) + .await?; let mut url = match request { Ok(Some(url)) => url, Ok(None) => return Ok(()), @@ -167,7 +172,11 @@ impl RequestHandler { "59 The server (Windmark) received a bad request: {error}\r\n" ); - stream.write_all(response.as_bytes()).await?; + with_timeout( + self.connection_limits.write_timeout, + stream.write_all(response.as_bytes()), + ) + .await??; return Ok(()); } @@ -260,7 +269,11 @@ impl RequestHandler { }; let response = serialize_response(content, &status_line, &header, &footer); - stream.write_all(&response).await?; + with_timeout( + self.connection_limits.write_timeout, + stream.write_all(&response), + ) + .await??; Ok(()) } @@ -365,65 +378,6 @@ fn render_footer( footer } -/// Spawn a task to complete the TLS handshake on an accepted connection and -/// dispatch it through `handler`. -fn spawn_connection( - handler: Arc, - acceptor: Arc, - stream: TcpStream, -) { - #[cfg(feature = "tokio")] - let spawner = tokio::spawn; - #[cfg(feature = "async-std")] - let spawner = async_std::task::spawn; - - spawner(async move { - let ssl = match ssl::Ssl::new(acceptor.context()) { - Ok(ssl) => ssl, - Err(e) => { - error!("ssl context error: {e:?}"); - - return; - } - }; - #[cfg(feature = "tokio")] - let quick_stream = tokio_openssl::SslStream::new(ssl, stream); - #[cfg(feature = "async-std")] - let quick_stream = async_std_openssl::SslStream::new(ssl, stream); - - match quick_stream { - Ok(mut stream) => { - if let Err(e) = std::pin::Pin::new(&mut stream).accept().await { - warn!("stream accept error: {e:?}"); - - return; - } - - if let Err(e) = handler.handle(&mut stream).await { - error!("handle error: {e}"); - - return; - } - - #[cfg(feature = "tokio")] - let shutdown = stream.shutdown(); - #[cfg(feature = "async-std")] - let shutdown = std::future::poll_fn(|context| { - async_std::io::Write::poll_close( - std::pin::Pin::new(&mut stream), - context, - ) - }); - - if let Err(error) = shutdown.await { - error!("TLS shutdown error: {error}"); - } - } - Err(e) => error!("ssl stream error: {e:?}"), - } - }); -} - /// Build a protocol-valid response header from the handler's response. fn status_line( content: &Response, @@ -742,8 +696,10 @@ impl Router { /// /// Under the default Tokio runtime, the server runs until it receives an /// interrupt (Ctrl+C / SIGINT), at which point it stops accepting new - /// connections and returns. The `async-std` runtime runs until the process - /// is terminated. + /// connections and drains for the configured period before returning. The + /// default drain period is zero. The `async-std` runtime runs until the + /// process is terminated; use `run_until` for application-controlled + /// shutdown. /// /// # Examples /// @@ -760,6 +716,54 @@ impl Router { /// This method returns an error if TLS configuration or listener binding /// fails. pub async fn run(&mut self) -> Result<(), Box> { + #[cfg(feature = "tokio")] + let shutdown = async { + let _ = tokio::signal::ctrl_c().await; + }; + #[cfg(feature = "async-std")] + let shutdown = std::future::pending(); + + self.run_until(shutdown).await + } + + /// Set connection limits for subsequent server starts. + /// + /// # Examples + /// + /// ```rust + /// let mut limits = windmark::router::ConnectionLimits::default(); + /// + /// limits.max_connections = std::num::NonZeroUsize::new(256); + /// limits.request_timeout = Some(std::time::Duration::from_secs(30)); + /// limits.drain_timeout = std::time::Duration::from_secs(5); + /// + /// windmark::router::Router::new().set_connection_limits(limits); + /// ``` + pub const fn set_connection_limits( + &mut self, + limits: ConnectionLimits, + ) -> &mut Self { + self.connection_limits = limits; + + self + } + + /// Serve requests until `shutdown` completes, then drain active connections + /// for the configured period. Remaining handler futures are cancelled. + /// Dropping this future also cancels the server's connection tasks. + /// + /// # Errors + /// + /// This method returns an error if TLS configuration or listener binding + /// fails. + /// + /// # Panics + /// + /// This method panics if a partial registry lock has been poisoned. + pub async fn run_until( + &mut self, + shutdown: impl std::future::Future, + ) -> Result<(), Box> { let acceptor = match &self.ssl_acceptor { Some(acceptor) => acceptor.clone(), None => Arc::new(self.create_acceptor()?), @@ -811,40 +815,12 @@ impl Router { modules: self.modules.clone(), module_scheduling: self.module_scheduling.clone(), options: self.options.clone(), + connection_limits: self.connection_limits, }); - // Under Tokio, Ctrl+C stops the server from accepting new connections - // without waiting for active connection tasks to finish. The async-std - // runtime continues accepting connections until the process exits. - #[cfg(feature = "tokio")] - { - loop { - tokio::select! { - connection = listener.accept() => match connection { - Ok((stream, _)) => - spawn_connection(handler.clone(), acceptor.clone(), stream), - Err(e) => error!("tcp stream error: {e:?}"), - }, - _ = tokio::signal::ctrl_c() => { - #[cfg(feature = "logger")] - info!("windmark received an interrupt; shutting down"); - - break; - } - } - } - - Ok(()) - } + connections::serve(listener, handler, acceptor, shutdown).await; - #[cfg(feature = "async-std")] - loop { - match listener.accept().await { - Ok((stream, _)) => - spawn_connection(handler.clone(), acceptor.clone(), stream), - Err(e) => error!("tcp stream error: {e:?}"), - } - } + Ok(()) } fn create_acceptor(&self) -> Result> { @@ -1406,6 +1382,7 @@ impl Default for Router { private_key_content: None, certificate_content: None, listener_address: "0.0.0.0".to_string(), + connection_limits: ConnectionLimits::default(), } } } diff --git a/src/router/connections.rs b/src/router/connections.rs new file mode 100644 index 0000000..c40f8e1 --- /dev/null +++ b/src/router/connections.rs @@ -0,0 +1,207 @@ +use super::{RequestHandler, Stream, TcpStream}; +use futures_util::{ + future::{AbortHandle, Abortable, BoxFuture}, + stream::FuturesUnordered, + FutureExt, + StreamExt, +}; +use openssl::ssl::{Ssl, SslAcceptor}; +use std::{ + future::Future, + io, + num::NonZeroUsize, + panic::AssertUnwindSafe, + pin::Pin, + sync::Arc, + task::{Context, Poll}, + time::Duration, +}; +#[cfg(feature = "tokio")] +use tokio::io::AsyncWriteExt; + +#[cfg(feature = "tokio")] +type TcpListener = tokio::net::TcpListener; +#[cfg(feature = "async-std")] +type TcpListener = async_std::net::TcpListener; + +/// These limits apply to each server when it starts. +/// +/// Defaults preserve unlimited connections and stage durations. A zero drain +/// timeout cancels active connections immediately when shutdown is requested. +/// Cancellation drops unfinished handler futures; it cannot undo their effects +/// or interrupt synchronous code that blocks a runtime worker. +#[derive(Clone, Copy, Debug, Default)] +#[non_exhaustive] +pub struct ConnectionLimits { + /// This cap includes connections waiting for their TLS handshake. + pub max_connections: Option, + /// This deadline covers the complete TLS handshake. + pub handshake_timeout: Option, + /// This deadline covers the complete request, including its CRLF terminator. + pub request_timeout: Option, + /// This deadline covers writing a response to the client. + pub write_timeout: Option, + /// This deadline covers sending the TLS close notification. + pub shutdown_timeout: Option, + /// Active connections may finish during this period after acceptance stops. + pub drain_timeout: Duration, +} + +pub(super) async fn with_timeout( + duration: Option, + future: F, +) -> io::Result { + let Some(duration) = duration else { + return Ok(future.await); + }; + #[cfg(feature = "tokio")] + let result = tokio::time::timeout(duration, future).await; + #[cfg(feature = "async-std")] + let result = async_std::future::timeout(duration, future).await; + + result.map_err(|_| { + io::Error::new(io::ErrorKind::TimedOut, "connection deadline elapsed") + }) +} + +struct ConnectionTask { + completion: BoxFuture<'static, ()>, + abort: AbortHandle, +} + +impl Future for ConnectionTask { + type Output = (); + + fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<()> { + self.completion.as_mut().poll(context) + } +} + +impl Drop for ConnectionTask { + fn drop(&mut self) { self.abort.abort(); } +} + +fn spawn_connection( + handler: Arc, + acceptor: Arc, + stream: TcpStream, +) -> ConnectionTask { + let (abort, registration) = AbortHandle::new_pair(); + let connection = async move { + let ssl = Ssl::new(acceptor.context())?; + let mut stream = Stream::new(ssl, stream)?; + let limits = handler.connection_limits; + + with_timeout(limits.handshake_timeout, Pin::new(&mut stream).accept()) + .await??; + + if let Err(error) = handler.handle(&mut stream).await { + error!("request handling error: {error}"); + + // An incomplete write must not look like a completed response to clients. + return Ok(()); + } + + #[cfg(feature = "tokio")] + let shutdown = stream.shutdown(); + #[cfg(feature = "async-std")] + let shutdown = std::future::poll_fn(|context| { + async_std::io::Write::poll_close(Pin::new(&mut stream), context) + }); + + with_timeout(limits.shutdown_timeout, shutdown).await??; + + Ok::<(), Box>(()) + }; + let connection = async move { + match AssertUnwindSafe(connection).catch_unwind().await { + Ok(Ok(())) => {} + Ok(Err(error)) => error!("connection error: {error}"), + Err(_) => error!("connection task panicked"), + } + }; + let connection = async move { + let _ = Abortable::new(connection, registration).await; + }; + #[cfg(feature = "tokio")] + let task = tokio::spawn(connection); + #[cfg(feature = "async-std")] + let task = async_std::task::spawn(connection); + let completion = async move { + #[cfg(feature = "tokio")] + if let Err(error) = task.await { + error!("connection task failed: {error}"); + } + + #[cfg(feature = "async-std")] + task.await; + } + .boxed(); + + ConnectionTask { + completion, + abort, + } +} + +pub(super) async fn serve( + listener: TcpListener, + handler: Arc, + acceptor: Arc, + shutdown: impl Future, +) { + let limits = handler.connection_limits; + let mut connections = FuturesUnordered::new(); + let shutdown = shutdown.fuse(); + + futures_util::pin_mut!(shutdown); + + loop { + let can_accept = limits + .max_connections + .is_none_or(|maximum| connections.len() < maximum.get()); + let event = { + let incoming = async { + if !can_accept { + std::future::pending::<()>().await; + } + + listener.accept().await + } + .fuse(); + let completed = async { + if connections.is_empty() { + std::future::pending::<()>().await; + } + + connections.next().await; + } + .fuse(); + + futures_util::pin_mut!(incoming, completed); + + futures_util::select_biased! { + () = shutdown => break, + () = completed => None, + connection = incoming => Some(connection), + } + }; + + if let Some(connection) = event { + match connection { + Ok((stream, _)) => + connections.push(spawn_connection( + handler.clone(), + acceptor.clone(), + stream, + )), + Err(error) => error!("TCP accept error: {error}"), + } + } + } + + drop(listener); + + let drain = async { while connections.next().await.is_some() {} }; + let _ = with_timeout(Some(limits.drain_timeout), drain).await; +} diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs index 99c1ebc..1885170 100644 --- a/tests/router_runtime.rs +++ b/tests/router_runtime.rs @@ -169,8 +169,16 @@ fn exchange( } async fn serve_requests( + router: Router, + client: impl FnOnce(SocketAddr) + Send + 'static, +) { + serve_requests_until(router, client, std::future::pending()).await; +} + +async fn serve_requests_until( mut router: Router, client: impl FnOnce(SocketAddr) + Send + 'static, + shutdown: impl std::future::Future + Send + 'static, ) { let reservation = TcpListener::bind("127.0.0.1:0").unwrap(); let address = reservation.local_addr().unwrap(); @@ -181,10 +189,13 @@ async fn serve_requests( drop(reservation); #[cfg(feature = "tokio")] - let server = tokio::spawn(async move { router.run().await.unwrap() }); + let server = + tokio::spawn(async move { router.run_until(shutdown).await.unwrap() }); #[cfg(feature = "async-std")] let server = - async_std::task::spawn(async move { router.run().await.unwrap() }); + async_std::task::spawn( + async move { router.run_until(shutdown).await.unwrap() }, + ); #[cfg(feature = "tokio")] let run_client = tokio::task::spawn_blocking; #[cfg(feature = "async-std")] @@ -689,6 +700,198 @@ async fn index_route_attributes_preserve_names_and_select_the_root_path() { .await; } +async fn pause(duration: Duration) { + #[cfg(feature = "tokio")] + tokio::time::sleep(duration).await; + #[cfg(feature = "async-std")] + async_std::task::sleep(duration).await; +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn connection_deadlines_release_stalled_handshakes_and_requests() { + let mut router = Router::new(); + let mut limits = windmark::router::ConnectionLimits::default(); + + limits.max_connections = std::num::NonZeroUsize::new(1); + limits.handshake_timeout = Some(Duration::from_millis(50)); + limits.request_timeout = Some(Duration::from_millis(50)); + limits.shutdown_timeout = Some(Duration::from_millis(50)); + + router.set_connection_limits(limits); + router.mount("/", |_| Response::success("ready")); + serve_requests(router, |address| { + let mut stalled = connect_tcp(address); + + stalled + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + + let result = stalled.read(&mut [0]); + + assert!( + matches!(result, Ok(0)) + || result.is_err_and(|error| { + error.kind() == std::io::ErrorKind::ConnectionReset + }) + ); + + let mut stalled = connect(address); + + stalled.write_all(b"gemini://localhost/").unwrap(); + + let started = Instant::now(); + let result = stalled.read(&mut [0]); + + assert!(result.is_err() || matches!(result, Ok(0))); + assert!(started.elapsed() < Duration::from_secs(2)); + request( + address, + "/", + b"20 text/gemini; charset=utf-8; lang=en\r\nready\n", + ); + }) + .await; +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn connection_admission_caps_active_handlers() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let active = Arc::new(AtomicUsize::new(0)); + let peak = Arc::new(AtomicUsize::new(0)); + let observed = peak.clone(); + let mut router = Router::new(); + let mut limits = windmark::router::ConnectionLimits::default(); + + limits.max_connections = std::num::NonZeroUsize::new(1); + + router.set_connection_limits(limits); + router.mount("/", move |_| { + let active = active.clone(); + let peak = peak.clone(); + + async move { + let count = active.fetch_add(1, Ordering::SeqCst) + 1; + + peak.fetch_max(count, Ordering::SeqCst); + pause(Duration::from_millis(40)).await; + active.fetch_sub(1, Ordering::SeqCst); + + Response::success("ready") + } + }); + serve_requests(router, move |address| { + let start = Arc::new(std::sync::Barrier::new(3)); + let clients: Vec<_> = (0..2) + .map(|_| { + let start = start.clone(); + + std::thread::spawn(move || { + start.wait(); + request( + address, + "/", + b"20 text/gemini; charset=utf-8; lang=en\r\nready\n", + ); + }) + }) + .collect(); + + start.wait(); + + for client in clients { + client.join().unwrap(); + } + + assert_eq!(observed.load(Ordering::SeqCst), 1); + }) + .await; +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn shutdown_drains_completed_handlers_and_cancels_unfinished_handlers() { + use std::sync::atomic::{AtomicBool, Ordering}; + + struct Completion(Arc); + + impl Drop for Completion { + fn drop(&mut self) { self.0.store(true, Ordering::SeqCst); } + } + + for finish in [true, false] { + let started = Arc::new(AtomicBool::new(false)); + let stopping = started.clone(); + let dropped = Arc::new(AtomicBool::new(false)); + let observed = dropped.clone(); + let mut router = Router::new(); + let mut limits = windmark::router::ConnectionLimits::default(); + + limits.drain_timeout = Duration::from_millis(100); + + router.set_connection_limits(limits); + router.mount("/", move |_| { + let completion = Completion(dropped.clone()); + let started = started.clone(); + + async move { + started.store(true, Ordering::SeqCst); + + if finish { + pause(Duration::from_millis(10)).await; + } else { + std::future::pending::<()>().await; + } + + drop(completion); + + Response::success("finished") + } + }); + serve_requests_until( + router, + move |address| { + let mut stream = connect(address); + let started = Instant::now(); + + if finish { + exchange( + stream, + &[b"gemini://localhost/\r\n"], + b"20 text/gemini; charset=utf-8; lang=en\r\nfinished\n", + ); + } else { + stream.write_all(b"gemini://localhost/\r\n").unwrap(); + + let result = stream.read(&mut [0]); + + assert!(result.is_err() || matches!(result, Ok(0))); + assert!(started.elapsed() < Duration::from_secs(2)); + } + + assert!(observed.load(Ordering::SeqCst)); + }, + async move { + while !stopping.load(Ordering::SeqCst) { + pause(Duration::from_millis(1)).await; + } + }, + ) + .await; + } +} + #[cfg_attr( feature = "tokio", tokio::test(flavor = "multi_thread", worker_threads = 2) @@ -753,3 +956,45 @@ async fn startup_preserves_an_existing_logger() { .await; } } + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn write_deadlines_release_connections_when_clients_stop_reading() { + use std::num::NonZeroUsize; + use windmark::router::ConnectionLimits; + + let mut limits = ConnectionLimits::default(); + + limits.max_connections = NonZeroUsize::new(1); + limits.write_timeout = Some(Duration::from_millis(50)); + + let mut router = Router::new(); + + router.set_connection_limits(limits); + router.mount("/large", |_| { + Response::binary_success( + vec![0; 32 * 1024 * 1024], + "application/octet-stream", + ) + }); + router.mount("/", |_| Response::success("ready")); + serve_requests(router, |address| { + let mut stalled = connect(address); + + stalled.write_all(b"gemini://localhost/large\r\n").unwrap(); + + let started = Instant::now(); + + request( + address, + "/", + b"20 text/gemini; charset=utf-8; lang=en\r\nready\n", + ); + assert!(started.elapsed() < Duration::from_secs(2)); + drop(stalled); + }) + .await; +} From 08cb09c34858454412f8e6dafb40b9b23b379cab Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 10:08:05 +0000 Subject: [PATCH 31/40] refactor(response)!: Store one payload behind explicit accessors BREAKING CHANGE: Response fields are private. Changing the status or payload kind requires replacing the response. --- Cargo.toml | 3 + examples/callbacks.rs | 4 +- src/response.rs | 131 ++++++++++++++---- src/router.rs | 19 +-- tests/binary_response.rs | 12 +- tests/consumer.rs | 1 + tests/consumer/src/bin/async_context.rs | 2 +- .../src/bin/invalid_response_fields.rs | 5 + tests/consumer/src/bin/valid.rs | 37 +++-- tests/response.rs | 60 ++++++++ tests/router.rs | 4 +- tests/router_runtime.rs | 5 +- 12 files changed, 221 insertions(+), 62 deletions(-) create mode 100644 tests/consumer/src/bin/invalid_response_fields.rs create mode 100644 tests/response.rs diff --git a/Cargo.toml b/Cargo.toml index e9a8c6a..fa8fd81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -133,3 +133,6 @@ name = "router_runtime" [[test]] name = "module" + +[[test]] +name = "response" diff --git a/examples/callbacks.rs b/examples/callbacks.rs index b6b4e8b..efecfc1 100644 --- a/examples/callbacks.rs +++ b/examples/callbacks.rs @@ -17,7 +17,9 @@ async fn main() -> Result<(), Box> { }) .set_post_route_callback( |context: &HookContext, content: &mut windmark::response::Response| { - content.content = content.content.replace("Hello", "Hi"); + if let Some(text) = content.content_mut() { + *text = text.replace("Hello", "Hi"); + } println!( "prepared response for {}", diff --git a/src/response.rs b/src/response.rs index f651cf1..d486b35 100644 --- a/src/response.rs +++ b/src/response.rs @@ -20,15 +20,17 @@ macro_rules! response { #[derive(Clone)] #[non_exhaustive] pub struct Response { - pub status: i32, - pub mime: Option, - pub content: String, - - /// The router emits these bytes verbatim instead of `content` for status - /// `21`/`22`. - pub binary_content: Option>, - pub character_set: Option, - pub languages: Option>, + status: i32, + payload: Payload, + mime: Option, + character_set: Option, + languages: Option>, +} + +#[derive(Clone)] +enum Payload { + Text(String), + Binary(Vec), } impl Response { @@ -78,7 +80,7 @@ impl Response { ) -> Self { let mut response = Self::new(21, String::new()); - response.binary_content = Some(content.as_ref().to_vec()); + response.payload = Payload::Binary(content.as_ref().to_vec()); response.with_mime(mime); @@ -92,7 +94,7 @@ impl Response { response.with_mime(tree_magic_mini::from_u8(content)); - response.binary_content = Some(content.to_vec()); + response.payload = Payload::Binary(content.to_vec()); response } @@ -102,8 +104,11 @@ impl Response { Self { status, mime: None, - content: content.into(), - binary_content: None, + payload: if matches!(status, 21 | 22) { + Payload::Binary(Vec::new()) + } else { + Payload::Text(content.into()) + }, character_set: None, languages: None, } @@ -112,8 +117,8 @@ impl Response { #[doc(hidden)] #[must_use] pub fn serialize_body(self, header: &str, footer: &str) -> Vec { - if matches!(self.status, 21 | 22) { - return self.binary_content.unwrap_or_default(); + if let Payload::Binary(bytes) = self.payload { + return bytes; } let mut body = Vec::with_capacity(self.body_capacity(header, footer)); @@ -123,11 +128,16 @@ impl Response { body } - pub(crate) fn body_capacity(&self, header: &str, footer: &str) -> usize { - match self.status { - 20 => header.len() + self.content.len() + footer.len() + 2, - 21 | 22 => self.binary_content.as_ref().map_or(0, Vec::len), - _ => 0, + pub(crate) const fn body_capacity( + &self, + header: &str, + footer: &str, + ) -> usize { + match &self.payload { + Payload::Text(content) if self.status == 20 => + header.len() + content.len() + footer.len() + 2, + Payload::Binary(bytes) => bytes.len(), + Payload::Text(_) => 0, } } @@ -137,8 +147,8 @@ impl Response { header: &str, footer: &str, ) { - match self.status { - 20 => { + match &self.payload { + Payload::Text(content) if self.status == 20 => { let text = self.mime.as_deref().is_none_or(|mime| { mime .split_once('/') @@ -147,10 +157,10 @@ impl Response { if text { append_text(body, header); - append_text(body, &self.content); + append_text(body, content); } else { body.extend_from_slice(header.as_bytes()); - body.extend_from_slice(self.content.as_bytes()); + body.extend_from_slice(content.as_bytes()); } body.push(b'\n'); @@ -165,16 +175,79 @@ impl Response { body.extend_from_slice(footer.as_bytes()); } } + Payload::Binary(bytes) => body.extend_from_slice(bytes), + Payload::Text(_) => {} + } + } - 21 | 22 => - if let Some(bytes) = &self.binary_content { - body.extend_from_slice(bytes); - }, + /// Return the status supplied to the constructor, including internal binary + /// statuses 21 and 22. Replace the response to change its status or payload + /// kind. + #[must_use] + pub const fn status(&self) -> i32 { self.status } + + /// Return text content or header metadata, or `None` for binary responses. + #[must_use] + pub fn content(&self) -> Option<&str> { + match &self.payload { + Payload::Text(content) => Some(content), + Payload::Binary(_) => None, + } + } - _ => {} + /// Borrow text content or header metadata for editing. + pub const fn content_mut(&mut self) -> Option<&mut String> { + match &mut self.payload { + Payload::Text(content) => Some(content), + Payload::Binary(_) => None, } } + /// Return the binary payload, or `None` for text and metadata responses. + #[must_use] + pub fn binary_content(&self) -> Option<&[u8]> { + match &self.payload { + Payload::Binary(bytes) => Some(bytes), + Payload::Text(_) => None, + } + } + + /// Borrow the binary payload for editing or replacing its owned buffer. + pub const fn binary_content_mut(&mut self) -> Option<&mut Vec> { + match &mut self.payload { + Payload::Binary(bytes) => Some(bytes), + Payload::Text(_) => None, + } + } + + /// Return the response's media type override. + #[must_use] + pub fn mime(&self) -> Option<&str> { self.mime.as_deref() } + + /// Borrow the media type override; set it to `None` to restore the default. + pub const fn mime_mut(&mut self) -> &mut Option { &mut self.mime } + + /// Return the response's character set override. + #[must_use] + pub fn character_set(&self) -> Option<&str> { self.character_set.as_deref() } + + /// Borrow the character set override; set it to `None` to use the router's + /// default. + pub const fn character_set_mut(&mut self) -> &mut Option { + &mut self.character_set + } + + /// Return the response's language override, including an explicitly empty + /// list. + #[must_use] + pub fn languages(&self) -> Option<&[String]> { self.languages.as_deref() } + + /// Borrow the language override; set it to `None` to use the router's + /// default. + pub const fn languages_mut(&mut self) -> &mut Option> { + &mut self.languages + } + pub fn with_mime( &mut self, mime: impl Into + AsRef, diff --git a/src/router.rs b/src/router.rs index a26123a..2886df5 100644 --- a/src/router.rs +++ b/src/router.rs @@ -384,20 +384,18 @@ fn status_line( default_character_set: &str, default_languages: &str, ) -> Result { - let status = content.status; + let status = content.status(); if matches!(status, 20..=22) { - let mime = content.mime.as_deref().unwrap_or(if status == 20 { + let mime = content.mime().unwrap_or(if status == 20 { "text/gemini" } else { "application/octet-stream" }); let metadata = if status == 20 { - let character_set = content - .character_set - .as_deref() - .unwrap_or(default_character_set); - let languages = content.languages.as_ref().map_or_else( + let character_set = + content.character_set().unwrap_or(default_character_set); + let languages = content.languages().map_or_else( || default_languages.to_owned(), |languages| languages.join(","), ); @@ -434,7 +432,12 @@ fn status_line( return Err("undefined response status"); } - let metadata = content.content.lines().next().unwrap_or_default(); + let metadata = content + .content() + .unwrap_or_default() + .lines() + .next() + .unwrap_or_default(); if metadata.chars().any(char::is_control) { return Err("response metadata contains a control character"); diff --git a/tests/binary_response.rs b/tests/binary_response.rs index e05caf9..06cc3b5 100644 --- a/tests/binary_response.rs +++ b/tests/binary_response.rs @@ -11,9 +11,9 @@ const NON_UTF8: &[u8] = &[ fn binary_success_preserves_non_utf8_bytes() { let response = Response::binary_success(NON_UTF8, "image/png"); - assert_eq!(response.status, 21); - assert_eq!(response.mime.as_deref(), Some("image/png")); - assert_eq!(response.binary_content.as_deref(), Some(NON_UTF8)); + assert_eq!(response.status(), 21); + assert_eq!(response.mime(), Some("image/png")); + assert_eq!(response.binary_content(), Some(NON_UTF8)); } #[test] @@ -29,8 +29,8 @@ fn serialize_body_writes_binary_bytes_verbatim() { fn binary_success_auto_preserves_non_utf8_bytes() { let response = Response::binary_success_auto(NON_UTF8); - assert_eq!(response.status, 22); - assert_eq!(response.binary_content.as_deref(), Some(NON_UTF8)); + assert_eq!(response.status(), 22); + assert_eq!(response.binary_content(), Some(NON_UTF8)); assert_eq!( response.serialize_body("ignored-header", "ignored-footer"), NON_UTF8, @@ -56,7 +56,7 @@ fn public_binary_serialization_returns_the_existing_allocation() { let pointer = bytes.as_ptr(); let capacity = bytes.capacity(); - response.binary_content = Some(bytes); + *response.binary_content_mut().unwrap() = bytes; let body = response.serialize_body("ignored-header", "ignored-footer"); diff --git a/tests/consumer.rs b/tests/consumer.rs index 70b1b5b..4553c73 100644 --- a/tests/consumer.rs +++ b/tests/consumer.rs @@ -15,6 +15,7 @@ fn consumer_api_and_macro_forms_match_expected_behavior() { #[cfg(feature = "async-std")] let runtime = "async-std"; let cases = [ + ("invalid_response_fields", "private", "mime"), ("valid", "", "mime,auto-deduce-mime"), ("automatic_mime", "", "mime"), ("binary_context", "", "mime"), diff --git a/tests/consumer/src/bin/async_context.rs b/tests/consumer/src/bin/async_context.rs index 2973188..86aee05 100644 --- a/tests/consumer/src/bin/async_context.rs +++ b/tests/consumer/src/bin/async_context.rs @@ -23,7 +23,7 @@ fn main() { panic!("the response should be ready"); }; - assert_eq!(response.content, format!("Hello {path}")); + assert_eq!(response.content().unwrap(), format!("Hello {path}")); } let route = windmark::success_async!( diff --git a/tests/consumer/src/bin/invalid_response_fields.rs b/tests/consumer/src/bin/invalid_response_fields.rs new file mode 100644 index 0000000..345710a --- /dev/null +++ b/tests/consumer/src/bin/invalid_response_fields.rs @@ -0,0 +1,5 @@ +fn main() { + let mut response = windmark::response::Response::success("hello"); + + response.status = 21; +} diff --git a/tests/consumer/src/bin/valid.rs b/tests/consumer/src/bin/valid.rs index 0cf4666..7aa9a69 100644 --- a/tests/consumer/src/bin/valid.rs +++ b/tests/consumer/src/bin/valid.rs @@ -60,7 +60,9 @@ impl PreRouteHook for Observer { impl PostRouteHook for Observer { fn call(&self, _: &HookContext, response: &mut Response) { - response.content.push('!'); + if let Some(content) = response.content_mut() { + content.push('!'); + } } } @@ -127,39 +129,44 @@ fn main() { router.add_options(&[RouterOption::AllowCaseInsensitiveLookup]); assert_eq!(capsule.count, 7); assert!(capsule.label.is_empty()); - assert_eq!(Capsule::index(context()).content, "index"); + assert_eq!(Capsule::index(context()).content().unwrap(), "index"); let mut response = Response::input::("prompt".to_owned()); - response.status = 79; - response.mime = Some("text/plain; charset=iso-8859-1".to_owned()); - response.character_set = Some("iso-8859-1".to_owned()); - response.languages = Some(vec!["en".to_owned()]); - response.binary_content = Some(vec![0xff]); - response.content = "metadata".to_owned(); + assert_eq!(response.content(), Some("prompt")); + + response = Response::new(79, "metadata"); + *response.mime_mut() = Some("text/plain; charset=iso-8859-1".to_owned()); + *response.character_set_mut() = Some("iso-8859-1".to_owned()); + *response.languages_mut() = Some(vec!["en".to_owned()]); assert!(response.serialize_body("header", "footer").is_empty()); - assert_eq!(Response::binary_success(Bytes, "text/plain").status, 21); - assert_eq!(Response::binary_success_auto(b"hello").status, 22); - assert_eq!(windmark::binary_success!(b"hello")(context()).status, 22); + assert_eq!(Response::binary_success(Bytes, "text/plain").status(), 21); + assert_eq!(Response::binary_success_auto(b"hello").status(), 22); + assert_eq!(windmark::binary_success!(b"hello")(context()).status(), 22); assert_eq!( - windmark::binary_success_auto!(b"hello")(context()).status, + windmark::binary_success_auto!(b"hello")(context()).status(), 22 ); assert_eq!( windmark::binary_success!(request, request.url.path(), "text/plain")( context() ) - .status, + .status(), 21 ); assert_eq!( - windmark::success!(request, request.url.path())(context()).content, + windmark::success!(request, request.url.path())(context()) + .content() + .unwrap(), "/path" ); let body = "hello"; let mime = "text/plain"; - assert_eq!(windmark::binary_success!(body, mime)(context()).status, 21); + assert_eq!( + windmark::binary_success!(body, mime)(context()).status(), + 21 + ); } diff --git a/tests/response.rs b/tests/response.rs new file mode 100644 index 0000000..fd0303d --- /dev/null +++ b/tests/response.rs @@ -0,0 +1,60 @@ +use windmark::response::Response; + +#[test] +fn payload_access_is_exclusive_and_statuses_remain_numeric() { + for status in [10, 20, 40, 51, 79] { + let mut response = Response::new(status, "message"); + + assert_eq!(response.status(), status); + assert!(response.binary_content().is_none()); + assert!(response.binary_content_mut().is_none()); + response.content_mut().unwrap().push('!'); + assert_eq!(response.content(), Some("message!")); + } + + for status in [21, 22] { + let mut response = Response::new(status, "ignored"); + + assert_eq!(response.status(), status); + assert!(response.content().is_none()); + assert!(response.content_mut().is_none()); + response.binary_content_mut().unwrap().extend([0xff, 0]); + assert_eq!(response.binary_content(), Some([0xff, 0].as_slice())); + } +} + +#[test] +fn metadata_overrides_can_be_edited_and_cleared() { + let mut response = Response::success("body"); + + response.with_mime("text/plain"); + response.with_character_set("ascii"); + response.with_languages(["en", "fr"]); + assert_eq!(response.mime(), Some("text/plain")); + assert_eq!(response.character_set(), Some("ascii")); + assert_eq!(response.languages().unwrap(), ["en", "fr"]); + response.languages_mut().as_mut().unwrap().clear(); + assert_eq!(response.languages(), Some([].as_slice())); + + *response.mime_mut() = None; + *response.character_set_mut() = None; + *response.languages_mut() = None; + + assert!(response.mime().is_none()); + assert!(response.character_set().is_none()); + assert!(response.languages().is_none()); +} + +#[test] +fn replacing_a_response_discards_the_old_payload_and_overrides() { + let mut response = Response::binary_success([0xff], "image/png"); + + assert!(response.binary_content().is_some()); + + response = Response::not_found("Missing"); + + assert_eq!(response.status(), 51); + assert_eq!(response.content(), Some("Missing")); + assert!(response.binary_content().is_none()); + assert!(response.mime().is_none()); +} diff --git a/tests/router.rs b/tests/router.rs index 955f65b..66f513a 100644 --- a/tests/router.rs +++ b/tests/router.rs @@ -265,7 +265,9 @@ fn response_assembly_preserves_publicly_constructible_states() { ] { let mut response = Response::new(status, "body"); - response.binary_content = Some(vec![0, 0xff]); + if let Some(bytes) = response.binary_content_mut() { + *bytes = vec![0, 0xff]; + } let status_line = line(&response); let expected = [expected_line.as_bytes(), b"\r\n", expected_body].concat(); diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs index 1885170..408bb86 100644 --- a/tests/router_runtime.rs +++ b/tests/router_runtime.rs @@ -272,7 +272,10 @@ async fn callbacks_partials_and_case_folding_keep_their_existing_order() { router.set_post_route_callback( move |_: &HookContext, response: &mut Response| { recorded.lock().unwrap().push("callback-post"); - response.content.push('!'); + + if let Some(content) = response.content_mut() { + content.push('!'); + } }, ); serve_requests(router, move |address| { From b93d9edd1e29cc7a35de03f814c7843a7b468a4e Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 10:13:34 +0000 Subject: [PATCH 32/40] refactor(module)!: Share request hooks without per-module locks BREAKING CHANGE: Request hooks receive &self and synchronous modules require Sync. Concurrent mode permits overlapping calls to the same module. --- examples/async_stateful_module.rs | 13 +++--- examples/stateful_module.rs | 13 +++--- src/module.rs | 27 ++++++------ src/router.rs | 72 +++++++++++++++++++------------ src/router_option.rs | 4 +- tests/consumer/src/bin/valid.rs | 15 ++++--- tests/router.rs | 42 +++++++++--------- tests/router_runtime.rs | 30 +++++-------- 8 files changed, 119 insertions(+), 97 deletions(-) diff --git a/examples/async_stateful_module.rs b/examples/async_stateful_module.rs index 30225d0..0e2e831 100644 --- a/examples/async_stateful_module.rs +++ b/examples/async_stateful_module.rs @@ -1,10 +1,11 @@ //! `cargo run --example async_stateful_module --features response-macros` +use std::sync::atomic::{AtomicUsize, Ordering}; use windmark::{context::HookContext, router::Router}; #[derive(Default)] struct Clicker { - clicks: usize, + clicks: AtomicUsize, } #[async_trait::async_trait] @@ -13,22 +14,22 @@ impl windmark::module::AsyncModule for Clicker { println!("module 'clicker' has been attached!"); } - async fn on_pre_route(&mut self, context: &HookContext) { - self.clicks += 1; + async fn on_pre_route(&self, context: &HookContext) { + let clicks = self.clicks.fetch_add(1, Ordering::Relaxed) + 1; println!( "module 'clicker' has been called before the route '{}' with {} clicks!", context.url.path(), - self.clicks + clicks ); } - async fn on_post_route(&mut self, context: &HookContext) { + async fn on_post_route(&self, context: &HookContext) { println!( "module 'clicker' clicker has been called after the route '{}' with {} \ clicks!", context.url.path(), - self.clicks + self.clicks.load(Ordering::Relaxed) ); } } diff --git a/examples/stateful_module.rs b/examples/stateful_module.rs index 42c4ca1..4b575bc 100644 --- a/examples/stateful_module.rs +++ b/examples/stateful_module.rs @@ -1,10 +1,11 @@ //! `cargo run --example stateful_module --features response-macros` +use std::sync::atomic::{AtomicUsize, Ordering}; use windmark::{context::HookContext, router::Router}; #[derive(Default)] struct Clicker { - clicks: usize, + clicks: AtomicUsize, } impl windmark::module::Module for Clicker { @@ -12,22 +13,22 @@ impl windmark::module::Module for Clicker { println!("module 'clicker' has been attached!"); } - fn on_pre_route(&mut self, context: &HookContext) { - self.clicks += 1; + fn on_pre_route(&self, context: &HookContext) { + let clicks = self.clicks.fetch_add(1, Ordering::Relaxed) + 1; println!( "module 'clicker' has been called before the route '{}' with {} clicks!", context.url.path(), - self.clicks, + clicks, ); } - fn on_post_route(&mut self, context: &HookContext) { + fn on_post_route(&self, context: &HookContext) { println!( "module 'clicker' clicker has been called after the route '{}' with {} \ clicks!", context.url.path(), - self.clicks, + self.clicks.load(Ordering::Relaxed), ); } } diff --git a/src/module.rs b/src/module.rs index 5c68f43..dc00db4 100644 --- a/src/module.rs +++ b/src/module.rs @@ -2,29 +2,32 @@ use crate::context::HookContext; /// Synchronous modules run hooks in registration order for each request. /// -/// Each module remains exclusive. Hook phases run exclusively across requests -/// unless `RouterOption::AllowConcurrentModules` is enabled. -/// If a hook panics, subsequent requests skip that module because its lock is -/// poisoned; other modules remain available. -pub trait Module { +/// Hook phases run exclusively across requests unless +/// `RouterOption::AllowConcurrentModules` is enabled. Concurrent phases may +/// invoke the same module simultaneously; modules own any synchronisation +/// needed by their state. Attachment still receives exclusive access. +/// If a hook panics, subsequent invocations skip that module. Invocations +/// already running may finish; other modules remain available. +pub trait Module: Send + Sync { /// The router calls this hook during attachment, before registering /// request hooks. fn on_attach(&mut self, _: &mut crate::router::Router) {} /// The router calls this hook before dispatching a request, even if the /// request does not match a route. - fn on_pre_route(&mut self, _: &HookContext) {} + fn on_pre_route(&self, _: &HookContext) {} /// The router calls this hook after the handler completes, before /// response serialisation. - fn on_post_route(&mut self, _: &HookContext) {} + fn on_post_route(&self, _: &HookContext) {} } /// Asynchronous modules run hooks in registration order for each request. /// -/// Each module remains exclusive, including across awaits. Hook phases run -/// exclusively across requests unless `RouterOption::AllowConcurrentModules` -/// is enabled. +/// Hook phases run exclusively across requests unless +/// `RouterOption::AllowConcurrentModules` is enabled. Concurrent phases may +/// invoke the same module while an earlier invocation awaits work. Modules +/// own their state synchronisation; attachment still receives exclusive access. #[async_trait::async_trait] pub trait AsyncModule: Send + Sync { /// The router calls this hook during attachment, before registering @@ -33,9 +36,9 @@ pub trait AsyncModule: Send + Sync { /// The router calls this hook before dispatching a request, even if the /// request does not match a route. - async fn on_pre_route(&mut self, _: &HookContext) {} + async fn on_pre_route(&self, _: &HookContext) {} /// The router calls this hook after the handler completes, before /// response serialisation. - async fn on_post_route(&mut self, _: &HookContext) {} + async fn on_post_route(&self, _: &HookContext) {} } diff --git a/src/router.rs b/src/router.rs index 2886df5..4565c40 100644 --- a/src/router.rs +++ b/src/router.rs @@ -51,8 +51,14 @@ type TcpStream = async_std::net::TcpStream; type Stream = tokio_openssl::SslStream; #[cfg(feature = "async-std")] type Stream = async_std_openssl::SslStream; -type SharedModule = Arc>>; -type SharedAsyncModule = Arc>>; + +struct ModuleEntry { + module: Box, + poisoned: AtomicBool, +} + +type SharedModule = Arc; +type SharedAsyncModule = Arc; #[derive(Default)] struct ModuleScheduling { @@ -282,15 +288,23 @@ impl RequestHandler { fn call_modules( modules: &Mutex>, scheduling: &ModuleScheduling, - hook: impl Fn(&mut dyn Module), + hook: impl Fn(&dyn Module), ) { let invoke = || { let modules = modules.lock().ok().map(|modules| modules.clone()); if let Some(modules) = modules { for module in modules { - if let Ok(mut module) = module.lock() { - hook(module.as_mut()); + if !module.poisoned.load(Ordering::Acquire) { + let result = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + hook(module.module.as_ref()); + })); + + if let Err(panic) = result { + module.poisoned.store(true, Ordering::Release); + std::panic::resume_unwind(panic); + } } } } @@ -323,8 +337,6 @@ async fn call_async_modules( let modules = modules.lock().await.clone(); for module in modules { - let mut module = module.lock().await; - match phase { HookPhase::PreRoute => module.on_pre_route(context).await, HookPhase::PostRoute => module.on_post_route(context).await, @@ -1072,7 +1084,7 @@ impl Router { /// /// #[derive(Default)] /// struct Clicker { - /// clicks: isize, + /// clicks: std::sync::atomic::AtomicUsize, /// } /// /// #[async_trait::async_trait] @@ -1081,21 +1093,24 @@ impl Router { /// info!("clicker has been attached!"); /// } /// - /// async fn on_pre_route(&mut self, context: &HookContext) { - /// self.clicks += 1; + /// async fn on_pre_route(&self, context: &HookContext) { + /// let clicks = self + /// .clicks + /// .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + /// + 1; /// /// info!( /// "clicker has been called pre-route on {} with {} clicks!", /// context.url.path(), - /// self.clicks + /// clicks /// ); /// } /// - /// async fn on_post_route(&mut self, context: &HookContext) { + /// async fn on_post_route(&self, context: &HookContext) { /// info!( /// "clicker has been called post-route on {} with {} clicks!", /// context.url.path(), - /// self.clicks + /// self.clicks.load(std::sync::atomic::Ordering::Relaxed) /// ); /// } /// } @@ -1110,8 +1125,7 @@ impl Router { mut module: impl AsyncModule + 'static, ) -> &mut Self { module.on_attach(self).await; - (*self.async_modules.lock().await) - .push(Arc::new(AsyncMutex::new(Box::new(module)))); + (*self.async_modules.lock().await).push(Arc::new(module)); self } @@ -1135,7 +1149,7 @@ impl Router { /// /// #[derive(Default)] /// struct Clicker { - /// clicks: isize, + /// clicks: std::sync::atomic::AtomicUsize, /// } /// /// impl windmark::module::Module for Clicker { @@ -1143,34 +1157,38 @@ impl Router { /// info!("clicker has been attached!"); /// } /// - /// fn on_pre_route(&mut self, context: &HookContext) { - /// self.clicks += 1; + /// fn on_pre_route(&self, context: &HookContext) { + /// let clicks = self + /// .clicks + /// .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + /// + 1; /// /// info!( /// "clicker has been called pre-route on {} with {} clicks!", /// context.url.path(), - /// self.clicks + /// clicks /// ); /// } /// - /// fn on_post_route(&mut self, context: &HookContext) { + /// fn on_post_route(&self, context: &HookContext) { /// info!( /// "clicker has been called post-route on {} with {} clicks!", /// context.url.path(), - /// self.clicks + /// self.clicks.load(std::sync::atomic::Ordering::Relaxed) /// ); /// } /// } /// /// Router::new().attach(Clicker::default()); /// ``` - pub fn attach( - &mut self, - mut module: impl Module + 'static + Send, - ) -> &mut Self { + pub fn attach(&mut self, mut module: impl Module + 'static) -> &mut Self { module.on_attach(self); - (*self.modules.lock().expect("modules lock poisoned")) - .push(Arc::new(Mutex::new(Box::new(module)))); + (*self.modules.lock().expect("modules lock poisoned")).push(Arc::new( + ModuleEntry { + module: Box::new(module), + poisoned: AtomicBool::new(false), + }, + )); self } diff --git a/src/router_option.rs b/src/router_option.rs index 4eebf68..5bd56da 100644 --- a/src/router_option.rs +++ b/src/router_option.rs @@ -12,8 +12,8 @@ pub enum RouterOption { /// Routes that conflict under this matching rule cannot be registered /// together while the option is enabled. AllowCaseInsensitiveLookup, - /// If enabled, different modules may run concurrently across requests. - /// Each module remains exclusive. By default, each synchronous or + /// If enabled, module hooks may run concurrently across requests, including + /// multiple invocations of the same module. By default, each synchronous or /// asynchronous hook phase runs exclusively across its module collection. /// /// This option is shared by cloned routers, including running servers. diff --git a/tests/consumer/src/bin/valid.rs b/tests/consumer/src/bin/valid.rs index 7aa9a69..e6a75e2 100644 --- a/tests/consumer/src/bin/valid.rs +++ b/tests/consumer/src/bin/valid.rs @@ -1,5 +1,8 @@ use rossweisse::route; -use std::{cell::Cell, future::IntoFuture}; +use std::{ + future::IntoFuture, + sync::atomic::{AtomicUsize, Ordering}, +}; use windmark::{ context::{ErrorContext, HookContext, RouteContext}, handler::{ @@ -33,17 +36,19 @@ impl Capsule { pub fn index(_: RouteContext) -> Response { Response::success("index") } } -struct Counter(Cell); +struct Counter(AtomicUsize); impl Module for Counter { - fn on_pre_route(&mut self, _: &HookContext) { self.0.set(self.0.get() + 1); } + fn on_pre_route(&self, _: &HookContext) { + self.0.fetch_add(1, Ordering::Relaxed); + } } struct AwaitableModule; #[async_trait::async_trait] impl AsyncModule for AwaitableModule { - async fn on_pre_route(&mut self, _: &HookContext) {} + async fn on_pre_route(&self, _: &HookContext) {} } struct Banner; @@ -120,7 +125,7 @@ fn main() { let _route: &dyn RouteResponse = &Replies; let _error: &dyn ErrorResponse = &Replies; - router.attach(Counter(Cell::new(0))); + router.attach(Counter(AtomicUsize::new(0))); router.add_header(Banner); router.add_footer(Banner); router.set_pre_route_callback(Observer); diff --git a/tests/router.rs b/tests/router.rs index 66f513a..a3cb3a3 100644 --- a/tests/router.rs +++ b/tests/router.rs @@ -433,29 +433,32 @@ fn trailing_slash_fixes_preserve_case_insensitive_parameters() { } #[test] -fn synchronous_module_locks_allow_other_modules_to_progress() { +fn shared_synchronous_modules_allow_overlapping_invocations() { struct SignalingModule(std::sync::mpsc::Sender<()>); impl crate::module::Module for SignalingModule { - fn on_pre_route(&mut self, _: &crate::context::HookContext) { + fn on_pre_route(&self, _: &crate::context::HookContext) { self.0.send(()).unwrap(); } } struct BlockingModule { entered: std::sync::mpsc::Sender<()>, - release: std::sync::mpsc::Receiver<()>, - blocked: bool, + release: std::sync::Mutex>, + blocked: std::sync::atomic::AtomicBool, } impl crate::module::Module for BlockingModule { - fn on_pre_route(&mut self, _: &crate::context::HookContext) { - if !self.blocked { - self.blocked = true; - + fn on_pre_route(&self, _: &crate::context::HookContext) { + if !self + .blocked + .swap(true, std::sync::atomic::Ordering::Relaxed) + { self.entered.send(()).unwrap(); self .release + .lock() + .unwrap() .recv_timeout(std::time::Duration::from_secs(5)) .unwrap(); } @@ -465,17 +468,16 @@ fn synchronous_module_locks_allow_other_modules_to_progress() { let (signal, progress) = std::sync::mpsc::channel(); let (entered, entry) = std::sync::mpsc::channel(); let (release, resume) = std::sync::mpsc::channel(); - let modules: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(vec![ - std::sync::Arc::new(std::sync::Mutex::new(Box::new(SignalingModule( - signal, - )))), - std::sync::Arc::new(std::sync::Mutex::new(Box::new(BlockingModule { - entered, - release: resume, - blocked: false, - }))), - ])); + let mut router = super::Router::new(); + + router.attach(SignalingModule(signal)); + router.attach(BlockingModule { + entered, + release: std::sync::Mutex::new(resume), + blocked: std::sync::atomic::AtomicBool::new(false), + }); + + let modules = router.modules.clone(); let scheduling = std::sync::Arc::new(super::ModuleScheduling::default()); let first_scheduling = scheduling.clone(); let first_modules = modules.clone(); @@ -514,9 +516,9 @@ fn synchronous_module_locks_allow_other_modules_to_progress() { }); let advanced = progress.recv_timeout(std::time::Duration::from_secs(5)); + second.join().unwrap(); release.send(()).unwrap(); first.join().unwrap(); - second.join().unwrap(); advanced.expect("the first module was blocked by the second module"); } diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs index 408bb86..6278498 100644 --- a/tests/router_runtime.rs +++ b/tests/router_runtime.rs @@ -30,7 +30,7 @@ impl Module for Observer { self.0.lock().unwrap().push("attach"); } - fn on_pre_route(&mut self, context: &HookContext) { + fn on_pre_route(&self, context: &HookContext) { if context.url.path() == "/Users/Alice" { assert_eq!( context.parameters.as_ref().unwrap().get("Name"), @@ -41,7 +41,7 @@ impl Module for Observer { self.0.lock().unwrap().push("module-pre"); } - fn on_post_route(&mut self, context: &HookContext) { + fn on_post_route(&self, context: &HookContext) { if context.url.path() == "/Users/Alice" { assert_eq!( context.parameters.as_ref().unwrap().get("Name"), @@ -66,12 +66,12 @@ impl AsyncModule for AsyncObserver { self.events.lock().unwrap().push("async-attach"); } - async fn on_pre_route(&mut self, _: &HookContext) { + async fn on_pre_route(&self, _: &HookContext) { assert!(self.initialized); self.events.lock().unwrap().push("async-pre"); } - async fn on_post_route(&mut self, _: &HookContext) { + async fn on_post_route(&self, _: &HookContext) { self.events.lock().unwrap().push("async-post"); } } @@ -483,13 +483,13 @@ impl GatedModule { #[async_trait::async_trait] impl AsyncModule for GatedModule { - async fn on_pre_route(&mut self, context: &HookContext) { + async fn on_pre_route(&self, context: &HookContext) { if self.enter(context, false) { self.gate.wait_async().await; } } - async fn on_post_route(&mut self, context: &HookContext) { + async fn on_post_route(&self, context: &HookContext) { if self.enter(context, true) { self.gate.wait_async().await; } @@ -501,7 +501,7 @@ impl AsyncModule for GatedModule { tokio::test(flavor = "multi_thread", worker_threads = 2) )] #[cfg_attr(feature = "async-std", async_std::test)] -async fn async_module_locks_allow_progress_in_both_hook_phases() { +async fn shared_async_modules_allow_progress_in_both_hook_phases() { for post_route in [false, true] { let mut router = Router::new(); let gate = Arc::new(HookGate::default()); @@ -579,18 +579,10 @@ async fn async_module_locks_allow_progress_in_both_hook_phases() { let progressed = progress.recv_timeout(Duration::from_secs(5)); - assert_eq!( - *events.lock().unwrap(), - [ - (false, "/first".to_owned()), - (true, "/first".to_owned()), - (false, "/second".to_owned()) - ] - ); + second.join().unwrap(); gate.release(); first.join().unwrap(); - second.join().unwrap(); - progressed.expect("the first module was blocked by the second module"); + progressed.expect("the second request did not reach the first module"); assert_eq!( *events.lock().unwrap(), [ @@ -610,14 +602,14 @@ struct PanickingModule { } impl Module for PanickingModule { - fn on_pre_route(&mut self, context: &HookContext) { + fn on_pre_route(&self, context: &HookContext) { assert!( self.post_route || context.url.path() != "/panic", "pre-route panic" ); } - fn on_post_route(&mut self, context: &HookContext) { + fn on_post_route(&self, context: &HookContext) { assert!( !self.post_route || context.url.path() != "/panic", "post-route panic" From 4fdd365b5b28047bf009286eb327aa7c4e004ede Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 10:14:41 +0000 Subject: [PATCH 33/40] refactor(logging)!: Let applications initialise their logger BREAKING CHANGE: The logger feature, enable_default_logger, and set_log_level are removed. Applications must install their own logging backend. --- .github/workflows/check.yaml | 4 +- Cargo.toml | 7 +-- README.md | 4 -- examples/README.md | 4 +- examples/async_stateful_module.rs | 10 ++- examples/default_logger.rs | 10 +-- examples/stateful_module.rs | 8 +-- src/router.rs | 100 ++++++------------------------ tests/README.md | 4 +- tests/router_runtime.rs | 2 - 10 files changed, 40 insertions(+), 113 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 7ed1bb7..f57d89c 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -30,9 +30,9 @@ jobs: - name: Default flags: --features windmark/tokio - name: Tokio - flags: --no-default-features --features windmark/tokio,windmark/logger,windmark/auto-deduce-mime,windmark/response-macros + flags: --no-default-features --features windmark/tokio,windmark/auto-deduce-mime,windmark/response-macros - name: Async-Std - flags: --no-default-features --features windmark/async-std,windmark/logger,windmark/auto-deduce-mime,windmark/response-macros + flags: --no-default-features --features windmark/async-std,windmark/auto-deduce-mime,windmark/response-macros name: Check ${{ matrix.name }} steps: - uses: actions/checkout@v4 diff --git a/Cargo.toml b/Cargo.toml index fa8fd81..d542ea2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,6 @@ categories = ["web-programming"] [features] default = ["tokio"] -logger = ["pretty_env_logger"] auto-deduce-mime = ["tree_magic_mini"] response-macros = [] tokio = ["dep:tokio", "tokio-openssl"] @@ -47,7 +46,6 @@ futures-util = "0.3.31" async-std = { version = "1.12.0", features = ["attributes"], optional = true } # Logging -pretty_env_logger = { version = "0.5.0", optional = true } log = "0.4.30" # URL @@ -61,13 +59,14 @@ tree_magic_mini = { version = "3.2.2", optional = true } # MIME paste = "1.0.12" # Token Pasting [dev-dependencies] +pretty_env_logger = "0.5.0" rossweisse = { version = "0.0.3", path = "./rossweisse" } [package.metadata.docs.rs] # `tokio` and `async-std` are mutually exclusive, so document the default # (`tokio`) runtime alongside the optional feature surface. all-features = false -features = ["response-macros", "logger", "auto-deduce-mime"] +features = ["response-macros", "auto-deduce-mime"] [[example]] name = "async" @@ -91,7 +90,7 @@ required-features = ["response-macros"] [[example]] name = "default_logger" -required-features = ["logger", "response-macros"] +required-features = ["response-macros"] [[example]] name = "fix_path" diff --git a/README.md b/README.md index bd4dc77..00bc7a3 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,6 @@ Now supporting both [Tokio](https://tokio.rs/) and [`async-std`](https://async.r | Feature | Description | | ------------------ | ------------------------------------------------------------------------------------------------------- | | `default` | Base Windmark framework using [Tokio](https://tokio.rs/) | -| `logger` | Enables the default [`pretty_env_logger`](https://github.com/seanmonstar/pretty-env-logger) integration | | `auto-deduce-mime` | Exposes `Response`s and macros that automatically fill MIMEs for non-Gemini responses | | `response-macros` | Simple macros for all `Response`s | | `tokio` | Marks [Tokio](https://tokio.rs/) as the asynchronous runtime | @@ -35,9 +34,6 @@ Now supporting both [Tokio](https://tokio.rs/) and [`async-std`](https://async.r windmark = "0.7.0" tokio = { version = "1.26.0", features = ["full"] } -# If you would like to use the built-in logger (recommended) -# windmark = { version = "0.7.0", features = ["logger"] } - # If you would like to use the built-in MIME deduction when `Success`-ing a file # (recommended) # windmark = { version = "0.7.0", features = ["auto-deduce-mime"] } diff --git a/examples/README.md b/examples/README.md index d3b23d1..f03dd71 100644 --- a/examples/README.md +++ b/examples/README.md @@ -40,9 +40,9 @@ reading the client certificate to give conditional access. ## [Default Logger](./default_logger.rs) -`cargo run --example default_logger --features logger,response-macros` +`cargo run --example default_logger --features response-macros` -A simple example showing the use of the default logger implementation. +A simple example showing how an application initialises a logger for Windmark. ## [Empty](./empty.rs) diff --git a/examples/async_stateful_module.rs b/examples/async_stateful_module.rs index 0e2e831..80a3a20 100644 --- a/examples/async_stateful_module.rs +++ b/examples/async_stateful_module.rs @@ -36,16 +36,14 @@ impl windmark::module::AsyncModule for Clicker { #[windmark::main] async fn main() -> Result<(), Box> { + pretty_env_logger::formatted_builder() + .parse_filters("windmark=trace") + .try_init()?; + let mut router = Router::new(); router.set_private_key_file("windmark_private.pem"); router.set_certificate_file("windmark_public.pem"); - - #[cfg(feature = "logger")] - { - router.enable_default_logger(true); - } - router.attach_async(Clicker::default()).await; router.mount("/", windmark::success!("Hello!")); router.run().await diff --git a/examples/default_logger.rs b/examples/default_logger.rs index 7d09b0a..79681e3 100644 --- a/examples/default_logger.rs +++ b/examples/default_logger.rs @@ -1,15 +1,15 @@ -//! `cargo run --example default_logger --features logger,response-macros` +//! `cargo run --example default_logger --features response-macros` #[windmark::main] async fn main() -> Result<(), Box> { + pretty_env_logger::formatted_builder() + .parse_filters("windmark=trace") + .try_init()?; + let mut router = windmark::router::Router::new(); router.set_private_key_file("windmark_private.pem"); router.set_certificate_file("windmark_public.pem"); - #[cfg(feature = "logger")] - { - router.enable_default_logger(true); - } router.mount( "/", windmark::success!({ diff --git a/examples/stateful_module.rs b/examples/stateful_module.rs index 4b575bc..d34c854 100644 --- a/examples/stateful_module.rs +++ b/examples/stateful_module.rs @@ -35,14 +35,14 @@ impl windmark::module::Module for Clicker { #[windmark::main] async fn main() -> Result<(), Box> { + pretty_env_logger::formatted_builder() + .parse_filters("windmark=trace") + .try_init()?; + let mut router = Router::new(); router.set_private_key_file("windmark_private.pem"); router.set_certificate_file("windmark_public.pem"); - #[cfg(feature = "logger")] - { - router.enable_default_logger(true); - } router.attach(Clicker::default()); router.mount("/", windmark::success!("Hello!")); diff --git a/src/router.rs b/src/router.rs index 4565c40..8e750a5 100644 --- a/src/router.rs +++ b/src/router.rs @@ -89,10 +89,6 @@ pub struct Router { headers: Arc>>>, footers: Arc>>>, ssl_acceptor: Option>, - #[cfg(feature = "logger")] - default_logger: bool, - #[cfg(feature = "logger")] - log_filter: String, pre_route_callback: Arc, post_route_callback: Arc, character_set: String, @@ -784,13 +780,6 @@ impl Router { None => Arc::new(self.create_acceptor()?), }; - #[cfg(feature = "logger")] - if self.default_logger { - let _ = pretty_env_logger::formatted_builder() - .parse_filters(&self.log_filter) - .try_init(); - } - #[cfg(feature = "tokio")] let listener = tokio::net::TcpListener::bind(format!( "{}:{}", @@ -804,7 +793,6 @@ impl Router { )) .await?; - #[cfg(feature = "logger")] info!("windmark is listening for connections"); let handler = Arc::new(RequestHandler { @@ -913,54 +901,6 @@ impl Router { self } - /// Enable the default logger (the - /// [`pretty_env_logger`](https://crates.io/crates/pretty_env_logger) and - /// [`log`](https://crates.io/crates/log) crates). - #[cfg(feature = "logger")] - pub fn enable_default_logger(&mut self, enable: bool) -> &mut Self { - self.default_logger = enable; - - if self.log_filter.is_empty() { - self.log_filter = "windmark=trace".to_string(); - } - - self - } - - /// Set the default logger's log level. - /// - /// If you enable Windmark's default logger with `enable_default_logger`, - /// Windmark will only emit its own logs. By setting a log level with - /// this method, you are overriding the default log level, so you must choose - /// to enable logs from Windmark with the `log_windmark` parameter. - /// - /// Log level "language" is detailed - /// [here](https://docs.rs/env_logger/0.9.0/env_logger/#enabling-logging). - /// - /// # Examples - /// - /// ```rust - /// windmark::router::Router::new() - /// .enable_default_logger(true) - /// .set_log_level("your_crate_name=trace", true); - /// // Use this setting to emit only logs from your crate. - /// // .set_log_level("your_crate_name=trace", false); - /// ``` - #[cfg(feature = "logger")] - pub fn set_log_level( - &mut self, - log_level: impl Into + AsRef, - log_windmark: bool, - ) -> &mut Self { - self.log_filter = format!( - "{}{}", - if log_windmark { "windmark," } else { "" }, - log_level.into() - ); - - self - } - /// Set a callback to run before a request is dispatched, even if the request /// does not match a route. /// @@ -1372,8 +1312,8 @@ impl Router { impl Default for Router { fn default() -> Self { Self { - routes: Routes::default(), - error_handler: Arc::new(|_| { + routes: Routes::default(), + error_handler: Arc::new(|_| { async { Response::not_found( "This capsule has not implemented an error handler...", @@ -1382,28 +1322,24 @@ impl Default for Router { }), private_key_file_name: String::new(), certificate_file_name: String::new(), - headers: Arc::new(Mutex::new(vec![])), - footers: Arc::new(Mutex::new(vec![])), - ssl_acceptor: None, - #[cfg(feature = "logger")] - default_logger: false, - #[cfg(feature = "logger")] - log_filter: String::new(), - pre_route_callback: Arc::new((|_| {}) as fn(&HookContext)), - post_route_callback: Arc::new( + headers: Arc::new(Mutex::new(vec![])), + footers: Arc::new(Mutex::new(vec![])), + ssl_acceptor: None, + pre_route_callback: Arc::new((|_| {}) as fn(&HookContext)), + post_route_callback: Arc::new( (|_, _: &mut Response| {}) as fn(&HookContext, &mut Response), ), - character_set: "utf-8".to_string(), - languages: vec!["en".to_string()], - port: 1965, - modules: Arc::new(Mutex::new(vec![])), - async_modules: Arc::new(AsyncMutex::new(vec![])), - module_scheduling: Arc::new(ModuleScheduling::default()), - options: HashSet::new(), - private_key_content: None, - certificate_content: None, - listener_address: "0.0.0.0".to_string(), - connection_limits: ConnectionLimits::default(), + character_set: "utf-8".to_string(), + languages: vec!["en".to_string()], + port: 1965, + modules: Arc::new(Mutex::new(vec![])), + async_modules: Arc::new(AsyncMutex::new(vec![])), + module_scheduling: Arc::new(ModuleScheduling::default()), + options: HashSet::new(), + private_key_content: None, + certificate_content: None, + listener_address: "0.0.0.0".to_string(), + connection_limits: ConnectionLimits::default(), } } } diff --git a/tests/README.md b/tests/README.md index de641c9..4d150e3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -4,8 +4,8 @@ Run the complete workspace with an explicitly selected runtime: ```sh cargo test --workspace --features windmark/tokio -cargo test --workspace --no-default-features --features windmark/tokio,windmark/logger,windmark/auto-deduce-mime,windmark/response-macros -cargo test --workspace --no-default-features --features windmark/async-std,windmark/logger,windmark/auto-deduce-mime,windmark/response-macros +cargo test --workspace --no-default-features --features windmark/tokio,windmark/auto-deduce-mime,windmark/response-macros +cargo test --workspace --no-default-features --features windmark/async-std,windmark/auto-deduce-mime,windmark/response-macros ``` The qualified runtime feature also selects the runtime for Rossweisse's diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs index 6278498..6bff66c 100644 --- a/tests/router_runtime.rs +++ b/tests/router_runtime.rs @@ -915,7 +915,6 @@ async fn invalid_response_headers_become_complete_failure_responses() { .await; } -#[cfg(feature = "logger")] #[cfg_attr( feature = "tokio", tokio::test(flavor = "multi_thread", worker_threads = 2) @@ -939,7 +938,6 @@ async fn startup_preserves_an_existing_logger() { for _ in 0..2 { let mut router = Router::new(); - router.enable_default_logger(true); router.mount("/", |_| Response::success("ready")); serve_requests(router, |address| { request( From bae8ad80fdfbe882e5f93569e1798873c3f7b7a3 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 10:19:12 +0000 Subject: [PATCH 34/40] feat(router): Separate bound servers from router configuration --- src/prelude.rs | 2 +- src/router.rs | 69 ++++++++++++++--------- src/router/connections.rs | 4 +- src/router/server.rs | 48 ++++++++++++++++ tests/router_runtime.rs | 115 ++++++++++++++++++++++++++++++++++---- 5 files changed, 197 insertions(+), 41 deletions(-) create mode 100644 src/router/server.rs diff --git a/src/prelude.rs b/src/prelude.rs index 5953f69..a13b23d 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -2,5 +2,5 @@ pub use crate::{ context, module::{AsyncModule, Module}, response::Response, - router::Router, + router::{Router, Server}, }; diff --git a/src/router.rs b/src/router.rs index 8e750a5..75442c0 100644 --- a/src/router.rs +++ b/src/router.rs @@ -35,10 +35,12 @@ use tokio::{ mod connections; mod request; mod routes; +mod server; -use connections::with_timeout; pub use connections::ConnectionLimits; +use connections::{with_timeout, TcpListener}; use routes::Routes; +pub use server::Server; const MAX_REQUEST_URI_BYTES: usize = 1024; @@ -727,14 +729,11 @@ impl Router { /// This method returns an error if TLS configuration or listener binding /// fails. pub async fn run(&mut self) -> Result<(), Box> { - #[cfg(feature = "tokio")] - let shutdown = async { - let _ = tokio::signal::ctrl_c().await; - }; - #[cfg(feature = "async-std")] - let shutdown = std::future::pending(); + let server = self.bind().await?; + + server.run().await; - self.run_until(shutdown).await + Ok(()) } /// Set connection limits for subsequent server starts. @@ -775,26 +774,40 @@ impl Router { &mut self, shutdown: impl std::future::Future, ) -> Result<(), Box> { + let server = self.bind().await?; + + server.run_until(shutdown).await; + + Ok(()) + } + + /// Bind a server using the current configuration. + /// + /// Routes, callbacks, partials, TLS settings, and connection limits are + /// captured at binding. Module registrations and module scheduling remain + /// shared with this router and its clones. State owned by handlers and + /// partials also retains its existing sharing semantics. + /// + /// The returned server owns the listener independently of this router. + /// Set the port to zero and call `Server::local_addr` to discover an + /// operating-system-assigned port before serving requests. + /// + /// # Errors + /// + /// This method returns an error if TLS configuration or listener binding + /// fails. + /// + /// # Panics + /// + /// This method panics if a partial registry lock has been poisoned. + pub async fn bind(&self) -> Result> { let acceptor = match &self.ssl_acceptor { Some(acceptor) => acceptor.clone(), None => Arc::new(self.create_acceptor()?), }; - - #[cfg(feature = "tokio")] - let listener = tokio::net::TcpListener::bind(format!( - "{}:{}", - self.listener_address, self.port - )) - .await?; - #[cfg(feature = "async-std")] - let listener = async_std::net::TcpListener::bind(format!( - "{}:{}", - self.listener_address, self.port - )) - .await?; - - info!("windmark is listening for connections"); - + let listener = + TcpListener::bind(format!("{}:{}", self.listener_address, self.port)) + .await?; let handler = Arc::new(RequestHandler { routes: self.routes.clone(), error_handler: self.error_handler.clone(), @@ -821,9 +834,11 @@ impl Router { connection_limits: self.connection_limits, }); - connections::serve(listener, handler, acceptor, shutdown).await; - - Ok(()) + Ok(Server { + listener, + handler, + acceptor, + }) } fn create_acceptor(&self) -> Result> { diff --git a/src/router/connections.rs b/src/router/connections.rs index c40f8e1..5fef9d9 100644 --- a/src/router/connections.rs +++ b/src/router/connections.rs @@ -20,9 +20,9 @@ use std::{ use tokio::io::AsyncWriteExt; #[cfg(feature = "tokio")] -type TcpListener = tokio::net::TcpListener; +pub(super) type TcpListener = tokio::net::TcpListener; #[cfg(feature = "async-std")] -type TcpListener = async_std::net::TcpListener; +pub(super) type TcpListener = async_std::net::TcpListener; /// These limits apply to each server when it starts. /// diff --git a/src/router/server.rs b/src/router/server.rs new file mode 100644 index 0000000..11f9837 --- /dev/null +++ b/src/router/server.rs @@ -0,0 +1,48 @@ +use super::{connections, RequestHandler, TcpListener}; +use openssl::ssl::SslAcceptor; +use std::{future::Future, io, net::SocketAddr, sync::Arc}; + +/// A server owns a bound listener and the configuration used to serve it. +/// +/// Create a server with `Router::bind`. Running it consumes the server; +/// dropping it before it runs releases the listener. Dropping its running +/// future also cancels active connection tasks. +pub struct Server { + pub(super) listener: TcpListener, + pub(super) handler: Arc, + pub(super) acceptor: Arc, +} + +impl Server { + /// Return the listener's local address, including its assigned port. + /// + /// # Errors + /// + /// This method returns an error if the operating system cannot retrieve + /// the listener's address. + pub fn local_addr(&self) -> io::Result { + self.listener.local_addr() + } + + /// Serve requests until an interrupt arrives under Tokio, then drain + /// connections for the configured period. Under `async-std`, serve until + /// the process terminates; use `run_until` for controlled shutdown. + pub async fn run(self) { + #[cfg(feature = "tokio")] + let shutdown = async { + let _ = tokio::signal::ctrl_c().await; + }; + #[cfg(feature = "async-std")] + let shutdown = std::future::pending(); + + self.run_until(shutdown).await; + } + + /// Serve requests until `shutdown` completes, then drain connections for + /// the configured period and cancel any remaining handler futures. + pub async fn run_until(self, shutdown: impl Future) { + info!("windmark is listening for connections"); + connections::serve(self.listener, self.handler, self.acceptor, shutdown) + .await; + } +} diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs index 6bff66c..3914d36 100644 --- a/tests/router_runtime.rs +++ b/tests/router_runtime.rs @@ -17,7 +17,7 @@ use windmark::{ context::{HookContext, RouteContext}, module::{AsyncModule, Module}, response::Response, - router::Router, + router::{Router, Server}, router_option::RouterOption, }; @@ -180,22 +180,26 @@ async fn serve_requests_until( client: impl FnOnce(SocketAddr) + Send + 'static, shutdown: impl std::future::Future + Send + 'static, ) { - let reservation = TcpListener::bind("127.0.0.1:0").unwrap(); - let address = reservation.local_addr().unwrap(); - router.set_listener_address("127.0.0.1"); - router.set_port(address.port()); + router.set_port(0); router.set_ssl_acceptor(acceptor()); - drop(reservation); + let bound = router.bind().await.unwrap(); + + serve_bound_requests(bound, client, shutdown).await; +} + +async fn serve_bound_requests( + bound: Server, + client: impl FnOnce(SocketAddr) + Send + 'static, + shutdown: impl std::future::Future + Send + 'static, +) { + let address = bound.local_addr().unwrap(); #[cfg(feature = "tokio")] - let server = - tokio::spawn(async move { router.run_until(shutdown).await.unwrap() }); + let server = tokio::spawn(async move { bound.run_until(shutdown).await }); #[cfg(feature = "async-std")] let server = - async_std::task::spawn( - async move { router.run_until(shutdown).await.unwrap() }, - ); + async_std::task::spawn(async move { bound.run_until(shutdown).await }); #[cfg(feature = "tokio")] let run_client = tokio::task::spawn_blocking; #[cfg(feature = "async-std")] @@ -991,3 +995,92 @@ async fn write_deadlines_release_connections_when_clients_stop_reading() { }) .await; } + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn bound_servers_own_their_ports_and_report_binding_errors() { + let mut router = Router::new(); + + router.set_listener_address("127.0.0.1"); + router.set_port(0); + router.set_ssl_acceptor(acceptor()); + + let server = router.bind().await.unwrap(); + let address = server.local_addr().unwrap(); + + assert_ne!(address.port(), 0); + router.set_port(address.port()); + assert!(router.bind().await.is_err()); + drop(server); + + let rebound = router.bind().await.unwrap(); + + assert_eq!(rebound.local_addr().unwrap(), address); + rebound.run_until(std::future::ready(())).await; + + let listener = TcpListener::bind(address).unwrap(); + + assert_eq!(listener.local_addr().unwrap(), address); +} + +#[cfg_attr( + feature = "tokio", + tokio::test(flavor = "multi_thread", worker_threads = 2) +)] +#[cfg_attr(feature = "async-std", async_std::test)] +async fn binding_captures_routes_but_preserves_shared_module_registration() { + let mut router = Router::new(); + let events: Events = Arc::default(); + + router.set_listener_address("127.0.0.1"); + router.set_port(0); + router.set_ssl_acceptor(acceptor()); + router.set_error_handler(|_| Response::success("before")); + + let first = router.bind().await.unwrap(); + + router.mount("/", |_| Response::success("after")); + + let second = router.bind().await.unwrap(); + + assert_ne!(first.local_addr().unwrap(), second.local_addr().unwrap()); + router.attach(Observer(events.clone())); + drop(router); + serve_bound_requests( + first, + |address| { + request( + address, + "/", + b"20 text/gemini; charset=utf-8; lang=en\r\nbefore\n", + ); + }, + std::future::pending(), + ) + .await; + serve_bound_requests( + second, + |address| { + request( + address, + "/", + b"20 text/gemini; charset=utf-8; lang=en\r\nafter\n", + ); + }, + std::future::pending(), + ) + .await; + assert_eq!( + *events.lock().unwrap(), + [ + "attach", + "module-pre", + "module-post", + "module-pre", + "module-post" + ] + ); +} From 19f5f61840b6e9af7bf0cfb50659456e564a86c5 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 10:49:55 +0000 Subject: [PATCH 35/40] fix(just): Remove the obsolete logger feature flag --- justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/justfile b/justfile index e3e89f8..fd33c4b 100644 --- a/justfile +++ b/justfile @@ -2,7 +2,7 @@ import? 'cargo.just' set allow-duplicate-recipes := true -default-features := "--features=logger,auto-deduce-mime,response-macros," +default-features := "--features=auto-deduce-mime,response-macros," default: @just --list From aa9ae4dc95c180a17e376ef1ae2b20638c5b3107 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 10:51:15 +0000 Subject: [PATCH 36/40] fix(just): Put the nightly toolchain on the task PATH --- justfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/justfile b/justfile index fd33c4b..0f75144 100644 --- a/justfile +++ b/justfile @@ -17,7 +17,8 @@ fmt: [private] generic-task task async-feature: - rustup run nightly cargo {{ task }} --no-default-features \ + PATH="$(dirname "$(rustup which --toolchain nightly rustc)"):$PATH" \ + cargo {{ task }} --no-default-features \ {{ default-features }}{{ async-feature }} check async-feature: From fc77340c158103153ebfd7f141fb44263dad7cd3 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 10:59:45 +0000 Subject: [PATCH 37/40] refactor(response)!: Replace public response macros with closures --- .github/workflows/check.yaml | 4 +- Cargo.toml | 53 +-------- README.md | 6 +- examples/README.md | 29 +++-- examples/async.rs | 17 +-- examples/async_stateful_module.rs | 6 +- examples/binary.rs | 16 +-- examples/callbacks.rs | 26 +++-- examples/certificate.rs | 9 +- examples/default_logger.rs | 16 ++- examples/fix_path.rs | 11 +- examples/parameters.rs | 22 ++-- examples/partial.rs | 6 +- examples/query.rs | 20 ++-- examples/responses.rs | 40 ++++--- examples/stateful_module.rs | 6 +- justfile | 2 +- src/response.rs | 3 - src/response/macros.rs | 101 ------------------ tests/README.md | 4 +- tests/consumer.rs | 52 +++++---- tests/consumer/Cargo.toml | 5 +- tests/consumer/src/bin/async_context.rs | 17 +-- tests/consumer/src/bin/automatic_mime.rs | 4 +- .../src/bin/automatic_mime_disabled.rs | 4 +- tests/consumer/src/bin/binary_context.rs | 16 +-- tests/consumer/src/bin/valid.rs | 26 ----- 27 files changed, 170 insertions(+), 351 deletions(-) delete mode 100644 src/response/macros.rs diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index f57d89c..2b55bd0 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -30,9 +30,9 @@ jobs: - name: Default flags: --features windmark/tokio - name: Tokio - flags: --no-default-features --features windmark/tokio,windmark/auto-deduce-mime,windmark/response-macros + flags: --no-default-features --features windmark/tokio,windmark/auto-deduce-mime - name: Async-Std - flags: --no-default-features --features windmark/async-std,windmark/auto-deduce-mime,windmark/response-macros + flags: --no-default-features --features windmark/async-std,windmark/auto-deduce-mime name: Check ${{ matrix.name }} steps: - uses: actions/checkout@v4 diff --git a/Cargo.toml b/Cargo.toml index d542ea2..928bb3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,6 @@ categories = ["web-programming"] [features] default = ["tokio"] auto-deduce-mime = ["tree_magic_mini"] -response-macros = [] tokio = ["dep:tokio", "tokio-openssl"] async-std = ["dep:async-std", "async-std-openssl"] @@ -56,8 +55,6 @@ language-tags = "0.3.2" tree_magic_mini = { version = "3.2.2", optional = true } # MIME -paste = "1.0.12" # Token Pasting - [dev-dependencies] pretty_env_logger = "0.5.0" rossweisse = { version = "0.0.3", path = "./rossweisse" } @@ -66,55 +63,7 @@ rossweisse = { version = "0.0.3", path = "./rossweisse" } # `tokio` and `async-std` are mutually exclusive, so document the default # (`tokio`) runtime alongside the optional feature surface. all-features = false -features = ["response-macros", "auto-deduce-mime"] - -[[example]] -name = "async" -required-features = ["response-macros"] - -[[example]] -name = "async_stateful_module" -required-features = ["response-macros"] - -[[example]] -name = "binary" -required-features = ["response-macros"] - -[[example]] -name = "callbacks" -required-features = ["response-macros"] - -[[example]] -name = "certificate" -required-features = ["response-macros"] - -[[example]] -name = "default_logger" -required-features = ["response-macros"] - -[[example]] -name = "fix_path" -required-features = ["response-macros"] - -[[example]] -name = "parameters" -required-features = ["response-macros"] - -[[example]] -name = "partial" -required-features = ["response-macros"] - -[[example]] -name = "query" -required-features = ["response-macros"] - -[[example]] -name = "responses" -required-features = ["response-macros"] - -[[example]] -name = "stateful_module" -required-features = ["response-macros"] +features = ["auto-deduce-mime"] [[example]] name = "simple_async_std" diff --git a/README.md b/README.md index 00bc7a3..dfbcf5b 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,7 @@ Now supporting both [Tokio](https://tokio.rs/) and [`async-std`](https://async.r | Feature | Description | | ------------------ | ------------------------------------------------------------------------------------------------------- | | `default` | Base Windmark framework using [Tokio](https://tokio.rs/) | -| `auto-deduce-mime` | Exposes `Response`s and macros that automatically fill MIMEs for non-Gemini responses | -| `response-macros` | Simple macros for all `Response`s | +| `auto-deduce-mime` | Enables `Response::binary_success_auto` to infer the MIME type of binary content | | `tokio` | Marks [Tokio](https://tokio.rs/) as the asynchronous runtime | | `async-std` | Marks [`async-std`](https://async.rs/) as the asynchronous runtime | @@ -37,9 +36,6 @@ tokio = { version = "1.26.0", features = ["full"] } # If you would like to use the built-in MIME deduction when `Success`-ing a file # (recommended) # windmark = { version = "0.7.0", features = ["auto-deduce-mime"] } - -# If you would like to use macro-based responses (as seen below) -# windmark = { version = "0.7.0", features = ["response-macros"] } ``` ### Implementing a Windmark Server diff --git a/examples/README.md b/examples/README.md index f03dd71..f9f5c5e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,7 +2,7 @@ ## [Async Stateful Module](./async_stateful_module.rs) -`cargo run --example async_stateful_module --features response-macros` +`cargo run --example async_stateful_module` Demonstrates use of the `AsyncModule` trait by implementing the module `Clicker` which tracks the global number of visits to the capsule. @@ -12,35 +12,34 @@ tracked for clicks. ## [Async](./async.rs) -`cargo run --example async --features response-macros` +`cargo run --example async` -This example shows how to define async routes with an async response macro -and implement a click tracker using a shared variable protected by an async -mutex. +This example shows how to define async route handlers and implement a click +tracker using a shared variable protected by an async mutex. ## [Binary](./binary.rs) -`cargo run --example binary --features response-macros` +`cargo run --example binary` Demonstrates the binary response functionality by using both manual and automatic mime resolution (`--features auto-deduce-mime`). ## [Callbacks](./callbacks.rs) -`cargo run --example callbacks --features response-macros` +`cargo run --example callbacks` This example shows how to register pre-route and post-route callbacks. ## [Certificate](./certificate.rs) -`cargo run --example certificate --features response-macros` +`cargo run --example certificate` Demonstrate the various certificate related responses as well as reading the client certificate to give conditional access. ## [Default Logger](./default_logger.rs) -`cargo run --example default_logger --features response-macros` +`cargo run --example default_logger` A simple example showing how an application initialises a logger for Windmark. @@ -60,7 +59,7 @@ the error handler. ## [Fix Path](./fix_path.rs) -`cargo run --example fix_path --features response-macros` +`cargo run --example fix_path` A simple example which demonstrates use of the path fixer that attempts to resolve the closest match of a route when an invalid route is visited. @@ -81,13 +80,13 @@ Demonstrate how to modify the MIME of a response before use. ## [Parameters](./parameters.rs) -`cargo run --example parameters --features response-macros` +`cargo run --example parameters` Demonstrate the use of route parameters (not URL queries). ## [Partial](./partial.rs) -`cargo run --example partial --features response-macros` +`cargo run --example partial` Demonstrates use of appending headers and footers to routes, globally. @@ -95,13 +94,13 @@ If you would like to conditionally append headers and footers based on route, pl ## [Query](./query.rs) -`cargo run --example query --features response-macros` +`cargo run --example query` Demonstrates the inspection of URL queries parameters. ## [Responses](./responses.rs) -`cargo run --example responses --features response-macros` +`cargo run --example responses` Demonstrates the use of a wide variety of responses, additionally exposing the flexibility of response bodies types. @@ -122,7 +121,7 @@ Demonstrates how to explicitly specify Windmark to use the [Tokio](https://githu ## [Stateful Module](./stateful_module.rs) -`cargo run --example stateful_module --features response-macros` +`cargo run --example stateful_module` Demonstrates use of `Module`s by implementing a click tracker diff --git a/examples/async.rs b/examples/async.rs index d7fe6f5..134ef68 100644 --- a/examples/async.rs +++ b/examples/async.rs @@ -1,4 +1,6 @@ -//! `cargo run --example async --features response-macros` +//! `cargo run --example async` + +use windmark::response::Response; #[windmark::main] async fn main() -> Result<(), Box> { @@ -18,15 +20,14 @@ async fn main() -> Result<(), Box> { *clicks += 1; - windmark::response::Response::success(*clicks) + Response::success(*clicks) + } + }); + router.mount("/async", |_| { + async { + Response::success("This response was sent using an asynchronous handler.") } }); - router.mount( - "/macro", - windmark::success_async!( - async { "This response was sent using an asynchronous macro." }.await - ), - ); router.run().await } diff --git a/examples/async_stateful_module.rs b/examples/async_stateful_module.rs index 80a3a20..5634301 100644 --- a/examples/async_stateful_module.rs +++ b/examples/async_stateful_module.rs @@ -1,7 +1,7 @@ -//! `cargo run --example async_stateful_module --features response-macros` +//! `cargo run --example async_stateful_module` use std::sync::atomic::{AtomicUsize, Ordering}; -use windmark::{context::HookContext, router::Router}; +use windmark::{context::HookContext, response::Response, router::Router}; #[derive(Default)] struct Clicker { @@ -45,6 +45,6 @@ async fn main() -> Result<(), Box> { router.set_private_key_file("windmark_private.pem"); router.set_certificate_file("windmark_public.pem"); router.attach_async(Clicker::default()).await; - router.mount("/", windmark::success!("Hello!")); + router.mount("/", |_| Response::success("Hello!")); router.run().await } diff --git a/examples/binary.rs b/examples/binary.rs index ae8f5e3..3501b15 100644 --- a/examples/binary.rs +++ b/examples/binary.rs @@ -1,8 +1,10 @@ -//! `cargo run --example binary --features response-macros` +//! `cargo run --example binary` //! //! Optionally, you can run this example with the `auto-deduce-mime` feature //! enabled. +use windmark::response::Response; + #[windmark::main] async fn main() -> Result<(), Box> { let mut router = windmark::router::Router::new(); @@ -10,14 +12,14 @@ async fn main() -> Result<(), Box> { router.set_private_key_file("windmark_private.pem"); router.set_certificate_file("windmark_public.pem"); #[cfg(feature = "auto-deduce-mime")] - router.mount("/automatic", { - windmark::binary_success!(include_bytes!("../LICENSE-MIT")) + router.mount("/automatic", |_| { + Response::binary_success_auto(include_bytes!("../LICENSE-MIT")) }); - router.mount("/specific", { - windmark::binary_success!(include_bytes!("../LICENSE-MIT"), "text/plain") + router.mount("/specific", |_| { + Response::binary_success(include_bytes!("../LICENSE-MIT"), "text/plain") }); - router.mount("/direct", { - windmark::binary_success!("This is a string.", "text/plain") + router.mount("/direct", |_| { + Response::binary_success("This is a string.", "text/plain") }); router.run().await diff --git a/examples/callbacks.rs b/examples/callbacks.rs index efecfc1..cd5cad9 100644 --- a/examples/callbacks.rs +++ b/examples/callbacks.rs @@ -1,13 +1,13 @@ -//! `cargo run --example callbacks --features response-macros` +//! `cargo run --example callbacks` -use windmark::context::HookContext; +use windmark::{context::HookContext, response::Response}; #[windmark::main] async fn main() -> Result<(), Box> { windmark::router::Router::new() .set_private_key_file("windmark_private.pem") .set_certificate_file("windmark_public.pem") - .mount("/", windmark::success!("Hello!")) + .mount("/", |_| Response::success("Hello!")) .set_pre_route_callback(|context: &HookContext| { println!( "accepted connection from {} to {}", @@ -15,18 +15,16 @@ async fn main() -> Result<(), Box> { context.url ) }) - .set_post_route_callback( - |context: &HookContext, content: &mut windmark::response::Response| { - if let Some(text) = content.content_mut() { - *text = text.replace("Hello", "Hi"); - } + .set_post_route_callback(|context: &HookContext, content: &mut Response| { + if let Some(text) = content.content_mut() { + *text = text.replace("Hello", "Hi"); + } - println!( - "prepared response for {}", - context.peer_address.unwrap().ip() - ) - }, - ) + println!( + "prepared response for {}", + context.peer_address.unwrap().ip() + ) + }) .run() .await } diff --git a/examples/certificate.rs b/examples/certificate.rs index 6c3e1d0..abc2ea9 100644 --- a/examples/certificate.rs +++ b/examples/certificate.rs @@ -1,4 +1,4 @@ -//! `cargo run --example certificate --features response-macros` +//! `cargo run --example certificate` use windmark::response::Response; @@ -26,10 +26,9 @@ async fn main() -> Result<(), Box> { ) } }) - .mount( - "/invalid", - windmark::certificate_not_valid!("Your certificate is invalid."), - ) + .mount("/invalid", |_| { + Response::certificate_not_valid("Your certificate is invalid.") + }) .run() .await } diff --git a/examples/default_logger.rs b/examples/default_logger.rs index 79681e3..ea79d23 100644 --- a/examples/default_logger.rs +++ b/examples/default_logger.rs @@ -1,4 +1,6 @@ -//! `cargo run --example default_logger --features response-macros` +//! `cargo run --example default_logger` + +use windmark::response::Response; #[windmark::main] async fn main() -> Result<(), Box> { @@ -10,14 +12,10 @@ async fn main() -> Result<(), Box> { router.set_private_key_file("windmark_private.pem"); router.set_certificate_file("windmark_public.pem"); - router.mount( - "/", - windmark::success!({ - log::info!("Hello!"); - - "Hello!" - }), - ); + router.mount("/", |_| { + log::info!("Hello!"); + Response::success("Hello!") + }); router.run().await } diff --git a/examples/fix_path.rs b/examples/fix_path.rs index fc87476..e409bf9 100644 --- a/examples/fix_path.rs +++ b/examples/fix_path.rs @@ -1,6 +1,6 @@ -//! `cargo run --example fix_path --features response-macros` +//! `cargo run --example fix_path` -use windmark::router_option::RouterOption; +use windmark::{response::Response, router_option::RouterOption}; #[windmark::main] async fn main() -> Result<(), Box> { @@ -12,10 +12,9 @@ async fn main() -> Result<(), Box> { RouterOption::AddMissingTrailingSlash, RouterOption::AllowCaseInsensitiveLookup, ]) - .mount( - "/close", - windmark::success!("Visit '/close/'; you should be close enough!"), - ) + .mount("/close", |_| { + Response::success("Visit '/close/'; you should be close enough!") + }) .run() .await } diff --git a/examples/parameters.rs b/examples/parameters.rs index 153e64a..5479671 100644 --- a/examples/parameters.rs +++ b/examples/parameters.rs @@ -1,6 +1,6 @@ -//! `cargo run --example parameters --features response-macros` +//! `cargo run --example parameters` -use windmark::success; +use windmark::response::Response; #[windmark::main] async fn main() -> Result<(), Box> { @@ -9,24 +9,22 @@ async fn main() -> Result<(), Box> { .set_certificate_file("windmark_public.pem") .mount( "/language/:language", - success!( - context, - format!( + |context: windmark::context::RouteContext| { + Response::success(format!( "Your language of choice is {}.", context.parameters.get("language").unwrap() - ) - ), + )) + }, ) .mount( "/name/:first/:last", - success!( - context, - format!( + |context: windmark::context::RouteContext| { + Response::success(format!( "Your name is {} {}.", context.parameters.get("first").unwrap(), context.parameters.get("last").unwrap() - ) - ), + )) + }, ) .run() .await diff --git a/examples/partial.rs b/examples/partial.rs index e92c8b8..7583986 100644 --- a/examples/partial.rs +++ b/examples/partial.rs @@ -1,4 +1,6 @@ -//! `cargo run --example partial --features response-macros` +//! `cargo run --example partial` + +use windmark::response::Response; #[windmark::main] async fn main() -> Result<(), Box> { @@ -14,7 +16,7 @@ async fn main() -> Result<(), Box> { .add_footer(|_: &windmark::context::RouteContext| { "\nCopyright (C) 2022".to_string() }) - .mount("/", windmark::success!("Hello!")) + .mount("/", |_| Response::success("Hello!")) .run() .await } diff --git a/examples/query.rs b/examples/query.rs index 12a0a97..ca55a6f 100644 --- a/examples/query.rs +++ b/examples/query.rs @@ -1,20 +1,18 @@ -//! `cargo run --example query --features response-macros` +//! `cargo run --example query` + +use windmark::response::Response; #[windmark::main] async fn main() -> Result<(), Box> { windmark::router::Router::new() .set_private_key_file("windmark_private.pem") .set_certificate_file("windmark_public.pem") - .mount( - "/query", - windmark::success!( - context, - format!( - "You provided the following queries: '{:?}'", - windmark::utilities::queries_from_url(&context.url) - ) - ), - ) + .mount("/query", |context: windmark::context::RouteContext| { + Response::success(format!( + "You provided the following queries: '{:?}'", + windmark::utilities::queries_from_url(&context.url) + )) + }) .run() .await } diff --git a/examples/responses.rs b/examples/responses.rs index 656c734..2182e61 100644 --- a/examples/responses.rs +++ b/examples/responses.rs @@ -1,31 +1,29 @@ -//! `cargo run --example responses --features response-macros` +//! `cargo run --example responses` -use windmark::success; +use windmark::response::Response; #[windmark::main] async fn main() -> Result<(), Box> { windmark::router::Router::new() .set_private_key_file("windmark_private.pem") .set_certificate_file("windmark_public.pem") - .mount( - "/", - success!( - "# Index\n\nWelcome!\n\n=> /test Test Page\n=> /time Unix Epoch" - ), - ) - .mount("/test", success!("This is a test page.\n=> / back")) - .mount( - "/failure", - windmark::temporary_failure!("Woops ... temporarily."), - ) - .mount( - "/time", - success!(std::time::UNIX_EPOCH.elapsed().unwrap().as_nanos()), - ) - .mount( - "/redirect", - windmark::permanent_redirect!("gemini://localhost/test"), - ) + .mount("/", |_| { + Response::success( + "# Index\n\nWelcome!\n\n=> /test Test Page\n=> /time Unix Epoch", + ) + }) + .mount("/test", |_| { + Response::success("This is a test page.\n=> / back") + }) + .mount("/failure", |_| { + Response::temporary_failure("Woops ... temporarily.") + }) + .mount("/time", |_| { + Response::success(std::time::UNIX_EPOCH.elapsed().unwrap().as_nanos()) + }) + .mount("/redirect", |_| { + Response::permanent_redirect("gemini://localhost/test") + }) .run() .await } diff --git a/examples/stateful_module.rs b/examples/stateful_module.rs index d34c854..f9156f5 100644 --- a/examples/stateful_module.rs +++ b/examples/stateful_module.rs @@ -1,7 +1,7 @@ -//! `cargo run --example stateful_module --features response-macros` +//! `cargo run --example stateful_module` use std::sync::atomic::{AtomicUsize, Ordering}; -use windmark::{context::HookContext, router::Router}; +use windmark::{context::HookContext, response::Response, router::Router}; #[derive(Default)] struct Clicker { @@ -44,7 +44,7 @@ async fn main() -> Result<(), Box> { router.set_private_key_file("windmark_private.pem"); router.set_certificate_file("windmark_public.pem"); router.attach(Clicker::default()); - router.mount("/", windmark::success!("Hello!")); + router.mount("/", |_| Response::success("Hello!")); router.run().await } diff --git a/justfile b/justfile index 0f75144..4c636bc 100644 --- a/justfile +++ b/justfile @@ -2,7 +2,7 @@ import? 'cargo.just' set allow-duplicate-recipes := true -default-features := "--features=auto-deduce-mime,response-macros," +default-features := "--features=auto-deduce-mime," default: @just --list diff --git a/src/response.rs b/src/response.rs index d486b35..350e788 100644 --- a/src/response.rs +++ b/src/response.rs @@ -1,8 +1,5 @@ //! This module provides the response type returned by handlers. -#[cfg(feature = "response-macros")] -mod macros; - macro_rules! response { ($name:ident, $status:expr) => { pub fn $name(content: S) -> Self diff --git a/src/response/macros.rs b/src/response/macros.rs deleted file mode 100644 index aae1b05..0000000 --- a/src/response/macros.rs +++ /dev/null @@ -1,101 +0,0 @@ -macro_rules! sync_response { - ($($name:ident),*) => { - $( - /// This macro does not accept a trailing comma. - #[macro_export] - macro_rules! $name { - ($body:expr) => { - |_: $crate::context::RouteContext| $crate::response::Response::$name($body) - }; - ($context:ident, $body:expr) => { - |$context: $crate::context::RouteContext| $crate::response::Response::$name($body) - }; - } - )* - }; -} - -macro_rules! async_response { - ($($name:ident),*) => { - $(::paste::paste! { - /// This macro does not accept a trailing comma. - #[macro_export] - macro_rules! [< $name _async >] { - ($body:expr) => { - |_: $crate::context::RouteContext| async { $crate::response::Response::$name($body) } - }; - ($context:ident, $body:expr) => { - |$context: $crate::context::RouteContext| async { - // Move the request context while preserving borrows of other captures. - #[allow(clippy::redundant_locals)] - let $context = $context; - - $crate::response::Response::$name($body) - } - }; - } - })* - }; -} - -macro_rules! response { - ($($name:ident),* $(,)?) => { - $( - sync_response!($name); - async_response!($name); - )* - }; -} - -response!( - input, - sensitive_input, - success, - temporary_redirect, - permanent_redirect, - temporary_failure, - server_unavailable, - cgi_error, - proxy_error, - slow_down, - permanent_failure, - not_found, - gone, - proxy_refused, - bad_request, - client_certificate_required, - certificate_not_authorised, - certificate_not_valid, -); - -#[cfg(feature = "auto-deduce-mime")] -response!(binary_success_auto); - -/// Build a binary response handler with an explicit or inferred MIME type. -/// -/// Automatic inference requires Windmark's `auto-deduce-mime` feature. Use -/// `binary_success!(context => body)` to bind a context when inferring MIME; -/// the two-expression `(body, mime)` form always specifies MIME explicitly. -#[macro_export] -macro_rules! binary_success { - ($body:expr, $mime:expr $(,)?) => { - |_: $crate::context::RouteContext| { - $crate::response::Response::binary_success($body, $mime) - } - }; - ($body:expr $(,)?) => { - |_: $crate::context::RouteContext| { - $crate::response::Response::binary_success_auto($body) - } - }; - ($context:ident, $body:expr, $mime:expr $(,)?) => { - |$context: $crate::context::RouteContext| { - $crate::response::Response::binary_success($body, $mime) - } - }; - ($context:ident => $body:expr $(,)?) => { - |$context: $crate::context::RouteContext| { - $crate::response::Response::binary_success_auto($body) - } - }; -} diff --git a/tests/README.md b/tests/README.md index 4d150e3..b9b02d7 100644 --- a/tests/README.md +++ b/tests/README.md @@ -4,8 +4,8 @@ Run the complete workspace with an explicitly selected runtime: ```sh cargo test --workspace --features windmark/tokio -cargo test --workspace --no-default-features --features windmark/tokio,windmark/auto-deduce-mime,windmark/response-macros -cargo test --workspace --no-default-features --features windmark/async-std,windmark/auto-deduce-mime,windmark/response-macros +cargo test --workspace --no-default-features --features windmark/tokio,windmark/auto-deduce-mime +cargo test --workspace --no-default-features --features windmark/async-std,windmark/auto-deduce-mime ``` The qualified runtime feature also selects the runtime for Rossweisse's diff --git a/tests/consumer.rs b/tests/consumer.rs index 4553c73..1fbedb7 100644 --- a/tests/consumer.rs +++ b/tests/consumer.rs @@ -1,7 +1,7 @@ use std::{path::PathBuf, process::Command}; #[test] -fn consumer_api_and_macro_forms_match_expected_behavior() { +fn consumer_api_matches_expected_behavior() { let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/consumer/Cargo.toml"); let target = std::env::var_os("CARGO_TARGET_DIR") @@ -15,49 +15,61 @@ fn consumer_api_and_macro_forms_match_expected_behavior() { #[cfg(feature = "async-std")] let runtime = "async-std"; let cases = [ - ("invalid_response_fields", "private", "mime"), - ("valid", "", "mime,auto-deduce-mime"), - ("automatic_mime", "", "mime"), - ("binary_context", "", "mime"), - ("async_context", "", "mime"), + ("invalid_response_fields", "private", "auto-deduce-mime"), + ("valid", "", "auto-deduce-mime"), + ("automatic_mime", "", "auto-deduce-mime"), + ("binary_context", "", "auto-deduce-mime"), + ("async_context", "", "auto-deduce-mime"), ("automatic_mime_disabled", "binary_success_auto", ""), ( "invalid_router_item", "requires a struct or impl block", - "mime", + "auto-deduce-mime", + ), + ( + "invalid_route_item", + "requires a function", + "auto-deduce-mime", ), - ("invalid_route_item", "requires a function", "mime"), ( "invalid_router_fields", "requires a struct with named fields or a unit struct", - "mime", + "auto-deduce-mime", + ), + ("invalid_router_syntax", "expected", "auto-deduce-mime"), + ("invalid_route_syntax", "expected", "auto-deduce-mime"), + ("visibility", "", "auto-deduce-mime"), + ( + "visibility_private", + "struct `Capsule` is private", + "auto-deduce-mime", ), - ("invalid_router_syntax", "expected", "mime"), - ("invalid_route_syntax", "expected", "mime"), - ("visibility", "", "mime"), - ("visibility_private", "struct `Capsule` is private", "mime"), ( "visibility_restricted", "struct `Capsule` is private", - "mime", + "auto-deduce-mime", ), - ("index_name", "", "mime"), - ("router_declarations", "", "mime"), + ("index_name", "", "auto-deduce-mime"), + ("router_declarations", "", "auto-deduce-mime"), ( "invalid_router_duplicate", "duplicate field initialiser", - "mime", + "auto-deduce-mime", ), ( "invalid_router_unknown", "unknown field initialiser", - "mime", + "auto-deduce-mime", + ), + ( + "invalid_router_reserved", + "reserved by Rossweisse", + "auto-deduce-mime", ), - ("invalid_router_reserved", "reserved by Rossweisse", "mime"), ( "invalid_route_argument", "only supported route argument", - "mime", + "auto-deduce-mime", ), ]; diff --git a/tests/consumer/Cargo.toml b/tests/consumer/Cargo.toml index caa2bb9..3b00ab1 100644 --- a/tests/consumer/Cargo.toml +++ b/tests/consumer/Cargo.toml @@ -9,11 +9,10 @@ publish = false [features] tokio = ["windmark/tokio", "dep:tokio"] async-std = ["windmark/async-std"] -auto-deduce-mime = [] -mime = ["windmark/auto-deduce-mime"] +auto-deduce-mime = ["windmark/auto-deduce-mime"] [dependencies] -windmark = { path = "../..", default-features = false, features = ["response-macros"] } +windmark = { path = "../..", default-features = false } rossweisse = { path = "../../rossweisse" } async-trait = "0.1.68" tokio = { version = "1.52.3", optional = true, features = ["macros", "rt"] } diff --git a/tests/consumer/src/bin/async_context.rs b/tests/consumer/src/bin/async_context.rs index 86aee05..68fd1a8 100644 --- a/tests/consumer/src/bin/async_context.rs +++ b/tests/consumer/src/bin/async_context.rs @@ -1,12 +1,10 @@ use std::future::Future; -use windmark::context::RouteContext; +use windmark::{context::RouteContext, response::Response}; fn main() { - let greeting = String::from("Hello"); - let route = windmark::success_async!( - context, - format!("{greeting} {}", context.url.path()) - ); + let route = |context: RouteContext| { + async move { Response::success(std::future::ready(context.url.path()).await) } + }; for path in ["/one", "/two"] { let context = RouteContext { @@ -23,13 +21,8 @@ fn main() { panic!("the response should be ready"); }; - assert_eq!(response.content().unwrap(), format!("Hello {path}")); + assert_eq!(response.content().unwrap(), path); } - let route = windmark::success_async!( - context, - std::future::ready(context.url.path()).await - ); - windmark::router::Router::new().mount("/", route); } diff --git a/tests/consumer/src/bin/automatic_mime.rs b/tests/consumer/src/bin/automatic_mime.rs index 34a8aac..33752b3 100644 --- a/tests/consumer/src/bin/automatic_mime.rs +++ b/tests/consumer/src/bin/automatic_mime.rs @@ -1 +1,3 @@ -fn main() { let _ = windmark::binary_success!(b"hello"); } +fn main() { + let _ = windmark::response::Response::binary_success_auto(b"hello"); +} diff --git a/tests/consumer/src/bin/automatic_mime_disabled.rs b/tests/consumer/src/bin/automatic_mime_disabled.rs index 34a8aac..33752b3 100644 --- a/tests/consumer/src/bin/automatic_mime_disabled.rs +++ b/tests/consumer/src/bin/automatic_mime_disabled.rs @@ -1 +1,3 @@ -fn main() { let _ = windmark::binary_success!(b"hello"); } +fn main() { + let _ = windmark::response::Response::binary_success_auto(b"hello"); +} diff --git a/tests/consumer/src/bin/binary_context.rs b/tests/consumer/src/bin/binary_context.rs index 866d871..240f55c 100644 --- a/tests/consumer/src/bin/binary_context.rs +++ b/tests/consumer/src/bin/binary_context.rs @@ -1,8 +1,12 @@ +use windmark::{context::RouteContext, response::Response, router::Router}; + fn main() { - let body = b"hello"; - let mime = "text/plain"; - let _ = windmark::binary_success!(body, mime); - let _ = - windmark::binary_success!(context, context.url.path().as_bytes(), mime); - let _ = windmark::binary_success!(context => context.url.path().as_bytes()); + let mut router = Router::new(); + + router.mount("/explicit", |context: RouteContext| { + Response::binary_success(context.url.path().as_bytes(), "text/plain") + }); + router.mount("/inferred", |context: RouteContext| { + Response::binary_success_auto(context.url.path().as_bytes()) + }); } diff --git a/tests/consumer/src/bin/valid.rs b/tests/consumer/src/bin/valid.rs index e6a75e2..cec8a01 100644 --- a/tests/consumer/src/bin/valid.rs +++ b/tests/consumer/src/bin/valid.rs @@ -148,30 +148,4 @@ fn main() { assert!(response.serialize_body("header", "footer").is_empty()); assert_eq!(Response::binary_success(Bytes, "text/plain").status(), 21); assert_eq!(Response::binary_success_auto(b"hello").status(), 22); - assert_eq!(windmark::binary_success!(b"hello")(context()).status(), 22); - assert_eq!( - windmark::binary_success_auto!(b"hello")(context()).status(), - 22 - ); - assert_eq!( - windmark::binary_success!(request, request.url.path(), "text/plain")( - context() - ) - .status(), - 21 - ); - assert_eq!( - windmark::success!(request, request.url.path())(context()) - .content() - .unwrap(), - "/path" - ); - - let body = "hello"; - let mime = "text/plain"; - - assert_eq!( - windmark::binary_success!(body, mime)(context()).status(), - 21 - ); } From 415e1494e81c58d88e275757dc17caf5b165e703 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 11:39:45 +0000 Subject: [PATCH 38/40] chore(release): Prepare Windmark 0.8.0 and Rossweisse 0.0.4 --- Cargo.toml | 4 ++-- README.md | 4 ++-- rossweisse/Cargo.toml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 928bb3e..f423f89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = ["rossweisse"] [package] autotests = false name = "windmark" -version = "0.7.0" +version = "0.8.0" authors = ["Fuwn "] edition = "2021" description = "An elegant and highly performant async Gemini server framework" @@ -57,7 +57,7 @@ tree_magic_mini = { version = "3.2.2", optional = true } # MIME [dev-dependencies] pretty_env_logger = "0.5.0" -rossweisse = { version = "0.0.3", path = "./rossweisse" } +rossweisse = { version = "0.0.4", path = "./rossweisse" } [package.metadata.docs.rs] # `tokio` and `async-std` are mutually exclusive, so document the default diff --git a/README.md b/README.md index dfbcf5b..ddd36e6 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,12 @@ Now supporting both [Tokio](https://tokio.rs/) and [`async-std`](https://async.r # Cargo.toml [dependencies] -windmark = "0.7.0" +windmark = "0.8.0" tokio = { version = "1.26.0", features = ["full"] } # If you would like to use the built-in MIME deduction when `Success`-ing a file # (recommended) -# windmark = { version = "0.7.0", features = ["auto-deduce-mime"] } +# windmark = { version = "0.8.0", features = ["auto-deduce-mime"] } ``` ### Implementing a Windmark Server diff --git a/rossweisse/Cargo.toml b/rossweisse/Cargo.toml index b6d287f..c90bd30 100644 --- a/rossweisse/Cargo.toml +++ b/rossweisse/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "rossweisse" -version = "0.0.3" +version = "0.0.4" authors = ["Fuwn "] edition = "2021" description = "`struct`-based Router Framework for Windmark" From c4aef2cd7f41f63d1bcc1253395219dfe8268179 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 11:59:25 +0000 Subject: [PATCH 39/40] fix(ci): Select the pinned nightly through the toolchain input --- .github/workflows/check.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 2b55bd0..f743b13 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -13,8 +13,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@nightly-2026-05-25 + - uses: dtolnay/rust-toolchain@master with: + toolchain: nightly-2026-05-25 components: rustfmt - name: Check Formatting run: | From bce44673e0cf31d47a105dc093a35f6599cbf4c9 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Sat, 12 Sep 2026 11:59:25 +0000 Subject: [PATCH 40/40] test(router): Allow scheduling headroom in deadline checks --- tests/router_runtime.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/router_runtime.rs b/tests/router_runtime.rs index 3914d36..febb039 100644 --- a/tests/router_runtime.rs +++ b/tests/router_runtime.rs @@ -714,11 +714,12 @@ async fn pause(duration: Duration) { async fn connection_deadlines_release_stalled_handshakes_and_requests() { let mut router = Router::new(); let mut limits = windmark::router::ConnectionLimits::default(); + let timeout = Duration::from_secs(1); limits.max_connections = std::num::NonZeroUsize::new(1); - limits.handshake_timeout = Some(Duration::from_millis(50)); - limits.request_timeout = Some(Duration::from_millis(50)); - limits.shutdown_timeout = Some(Duration::from_millis(50)); + limits.handshake_timeout = Some(timeout); + limits.request_timeout = Some(timeout); + limits.shutdown_timeout = Some(timeout); router.set_connection_limits(limits); router.mount("/", |_| Response::success("ready")); @@ -746,7 +747,7 @@ async fn connection_deadlines_release_stalled_handshakes_and_requests() { let result = stalled.read(&mut [0]); assert!(result.is_err() || matches!(result, Ok(0))); - assert!(started.elapsed() < Duration::from_secs(2)); + assert!(started.elapsed() < Duration::from_secs(4)); request( address, "/",