From acbe7ecbf1f7bf59fd7ed9dd16a55ee256240aab Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Sat, 2 Dec 2023 14:03:40 +0100 Subject: [PATCH 1/9] Support graceful shutdown on `serve` --- Cargo.toml | 4 + axum/src/macros.rs | 12 ++ axum/src/serve.rs | 172 ++++++++++++++++++++++++- examples/graceful-shutdown/Cargo.toml | 2 +- examples/graceful-shutdown/src/main.rs | 113 ++-------------- 5 files changed, 193 insertions(+), 110 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a68aaab16a..17d2c4402b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,3 +5,7 @@ default-members = ["axum", "axum-*"] # Example has been deleted, but README.md remains exclude = ["examples/async-graphql"] resolver = "2" + +[patch.crates-io] +hyper = { git = "https://github.com/hyperium/hyper", rev = "cf68ea902749e" } +hyper-util = { git = "https://github.com/hyperium/hyper-util", rev = "64f896695c0f1" } diff --git a/axum/src/macros.rs b/axum/src/macros.rs index 180c3c05a5..e07b75345a 100644 --- a/axum/src/macros.rs +++ b/axum/src/macros.rs @@ -66,3 +66,15 @@ macro_rules! all_the_tuples { $name!([T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15], T16); }; } + +#[cfg(feature = "tracing")] +macro_rules! trace { + ($($tt:tt)*) => { + tracing::trace!($($tt)*) + }; +} + +#[cfg(not(feature = "tracing"))] +macro_rules! trace { + ($($tt:tt)*) => {}; +} diff --git a/axum/src/serve.rs b/axum/src/serve.rs index c6aa2784c8..38152283aa 100644 --- a/axum/src/serve.rs +++ b/axum/src/serve.rs @@ -2,23 +2,28 @@ use std::{ convert::Infallible, + fmt::Debug, future::{Future, IntoFuture}, io, marker::PhantomData, net::SocketAddr, - pin::Pin, + pin::{pin, Pin}, + sync::Arc, task::{Context, Poll}, }; use axum_core::{body::Body, extract::Request, response::Response}; -use futures_util::future::poll_fn; +use futures_util::{future::poll_fn, FutureExt}; use hyper::body::Incoming; use hyper_util::{ rt::{TokioExecutor, TokioIo}, server::conn::auto::Builder, }; use pin_project_lite::pin_project; -use tokio::net::{TcpListener, TcpStream}; +use tokio::{ + net::{TcpListener, TcpStream}, + sync::watch, +}; use tower::util::{Oneshot, ServiceExt}; use tower_service::Service; @@ -109,9 +114,25 @@ pub struct Serve { } #[cfg(all(feature = "tokio", any(feature = "http1", feature = "http2")))] -impl std::fmt::Debug for Serve +impl Serve { + /// TODO + pub fn with_graceful_shutdown(self, signal: F) -> WithGracefulShutdown + where + F: Future + Send + 'static, + { + WithGracefulShutdown { + tcp_listener: self.tcp_listener, + make_service: self.make_service, + signal, + _marker: PhantomData, + } + } +} + +#[cfg(all(feature = "tokio", any(feature = "http1", feature = "http2")))] +impl Debug for Serve where - M: std::fmt::Debug, + M: Debug, { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let Self { @@ -166,7 +187,7 @@ where service: tower_service, }; - tokio::task::spawn(async move { + tokio::spawn(async move { match Builder::new(TokioExecutor::new()) // upgrades needed for websockets .serve_connection_with_upgrades(tcp_stream, hyper_service) @@ -187,6 +208,145 @@ where } } +/// Serve future with graceful shutdown enabled. +#[cfg(all(feature = "tokio", any(feature = "http1", feature = "http2")))] +pub struct WithGracefulShutdown { + tcp_listener: TcpListener, + make_service: M, + signal: F, + _marker: PhantomData, +} + +#[cfg(all(feature = "tokio", any(feature = "http1", feature = "http2")))] +impl Debug for WithGracefulShutdown +where + M: Debug, + S: Debug, + F: Debug, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Self { + tcp_listener, + make_service, + signal, + _marker: _, + } = self; + + f.debug_struct("WithGracefulShutdown") + .field("tcp_listener", tcp_listener) + .field("make_service", make_service) + .field("signal", signal) + .finish() + } +} + +#[cfg(all(feature = "tokio", any(feature = "http1", feature = "http2")))] +impl IntoFuture for WithGracefulShutdown +where + M: for<'a> Service, Error = Infallible, Response = S> + Send + 'static, + for<'a> >>::Future: Send, + S: Service + Clone + Send + 'static, + S::Future: Send, + F: Future + Send + 'static, +{ + type Output = io::Result<()>; + type IntoFuture = private::ServeFuture; + + fn into_future(self) -> Self::IntoFuture { + let Self { + tcp_listener, + mut make_service, + signal, + _marker: _, + } = self; + + let (signal_tx, signal_rx) = watch::channel(()); + let signal_tx = Arc::new(signal_tx); + tokio::spawn(async move { + signal.await; + trace!("received graceful shutdown signal. Telling tasks to shutdown"); + drop(signal_rx); + }); + + let (close_tx, close_rx) = watch::channel(()); + + private::ServeFuture(Box::pin(async move { + loop { + let (tcp_stream, remote_addr) = tokio::select! { + result = tcp_listener.accept() => { + result? + } + _ = signal_tx.closed() => { + trace!("signal received, not accepting new connections"); + break; + } + }; + let tcp_stream = TokioIo::new(tcp_stream); + + trace!("connection {remote_addr} accepted"); + + poll_fn(|cx| make_service.poll_ready(cx)) + .await + .unwrap_or_else(|err| match err {}); + + let tower_service = make_service + .call(IncomingStream { + tcp_stream: &tcp_stream, + remote_addr, + }) + .await + .unwrap_or_else(|err| match err {}); + + let hyper_service = TowerToHyperService { + service: tower_service, + }; + + let signal_tx = Arc::clone(&signal_tx); + + let close_rx = close_rx.clone(); + + tokio::spawn(async move { + let builder = Builder::new(TokioExecutor::new()); + let conn = builder.serve_connection_with_upgrades(tcp_stream, hyper_service); + let mut conn = pin!(conn); + + let mut signal_closed = pin!(signal_tx.closed().fuse()); + + loop { + tokio::select! { + result = conn.as_mut() => { + if let Err(_err) = result { + trace!("failed to serve connection: {_err:#}"); + } + break; + } + _ = &mut signal_closed => { + trace!("signal received in task, starting graceful shutdown"); + conn.as_mut().graceful_shutdown(); + } + } + } + + trace!("connection {remote_addr} closed"); + + drop(close_rx); + }); + } + + drop(close_rx); + drop(tcp_listener); + + trace!( + "waiting for {} task(s) to finish", + close_tx.receiver_count() + ); + close_tx.closed().await; + + Ok(()) + })) + } +} + mod private { use std::{ future::Future, diff --git a/examples/graceful-shutdown/Cargo.toml b/examples/graceful-shutdown/Cargo.toml index fd9c09e35f..86dfd52763 100644 --- a/examples/graceful-shutdown/Cargo.toml +++ b/examples/graceful-shutdown/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" publish = false [dependencies] -axum = { path = "../../axum" } +axum = { path = "../../axum", features = ["tracing"] } hyper = { version = "1.0", features = [] } hyper-util = { version = "0.1", features = ["tokio", "server-auto", "http1"] } tokio = { version = "1.0", features = ["full"] } diff --git a/examples/graceful-shutdown/src/main.rs b/examples/graceful-shutdown/src/main.rs index 8932b54bee..984330715e 100644 --- a/examples/graceful-shutdown/src/main.rs +++ b/examples/graceful-shutdown/src/main.rs @@ -10,17 +10,12 @@ use std::time::Duration; -use axum::{extract::Request, routing::get, Router}; -use hyper::body::Incoming; -use hyper_util::rt::TokioIo; +use axum::{routing::get, Router}; use tokio::net::TcpListener; use tokio::signal; -use tokio::sync::watch; use tokio::time::sleep; -use tower::Service; use tower_http::timeout::TimeoutLayer; use tower_http::trace::TraceLayer; -use tracing::debug; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main] @@ -28,10 +23,11 @@ async fn main() { // Enable tracing. tracing_subscriber::registry() .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "example_graceful_shutdown=debug,tower_http=debug".into()), + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { + "example_graceful_shutdown=debug,tower_http=debug,axum=trace".into() + }), ) - .with(tracing_subscriber::fmt::layer()) + .with(tracing_subscriber::fmt::layer().without_time()) .init(); // Create a regular axum app. @@ -48,100 +44,11 @@ async fn main() { // Create a `TcpListener` using tokio. let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap(); - // Create a watch channel to track tasks that are handling connections and wait for them to - // complete. - let (close_tx, close_rx) = watch::channel(()); - - // Continuously accept new connections. - loop { - let (socket, remote_addr) = tokio::select! { - // Either accept a new connection... - result = listener.accept() => { - result.unwrap() - } - // ...or wait to receive a shutdown signal and stop the accept loop. - _ = shutdown_signal() => { - debug!("signal received, not accepting new connections"); - break; - } - }; - - debug!("connection {remote_addr} accepted"); - - // We don't need to call `poll_ready` because `Router` is always ready. - let tower_service = app.clone(); - - // Clone the watch receiver and move it into the task. - let close_rx = close_rx.clone(); - - // Spawn a task to handle the connection. That way we can serve multiple connections - // concurrently. - tokio::spawn(async move { - // Hyper has its own `AsyncRead` and `AsyncWrite` traits and doesn't use tokio. - // `TokioIo` converts between them. - let socket = TokioIo::new(socket); - - // Hyper also has its own `Service` trait and doesn't use tower. We can use - // `hyper::service::service_fn` to create a hyper `Service` that calls our app through - // `tower::Service::call`. - let hyper_service = hyper::service::service_fn(move |request: Request| { - // We have to clone `tower_service` because hyper's `Service` uses `&self` whereas - // tower's `Service` requires `&mut self`. - // - // We don't need to call `poll_ready` since `Router` is always ready. - tower_service.clone().call(request) - }); - - // `hyper_util::server::conn::auto::Builder` supports both http1 and http2 but doesn't - // support graceful so we have to use hyper directly and unfortunately pick between - // http1 and http2. - let conn = hyper::server::conn::http1::Builder::new() - .serve_connection(socket, hyper_service) - // `with_upgrades` is required for websockets. - .with_upgrades(); - - // `graceful_shutdown` requires a pinned connection. - let mut conn = std::pin::pin!(conn); - - loop { - tokio::select! { - // Poll the connection. This completes when the client has closed the - // connection, graceful shutdown has completed, or we encounter a TCP error. - result = conn.as_mut() => { - if let Err(err) = result { - debug!("failed to serve connection: {err:#}"); - } - break; - } - // Start graceful shutdown when we receive a shutdown signal. - // - // We use a loop to continue polling the connection to allow requests to finish - // after starting graceful shutdown. Our `Router` has `TimeoutLayer` so - // requests will finish after at most 10 seconds. - _ = shutdown_signal() => { - debug!("signal received, starting graceful shutdown"); - conn.as_mut().graceful_shutdown(); - } - } - } - - debug!("connection {remote_addr} closed"); - - // Drop the watch receiver to signal to `main` that this task is done. - drop(close_rx); - }); - } - - // We only care about the watch receivers that were moved into the tasks so close the residual - // receiver. - drop(close_rx); - - // Close the listener to stop accepting new connections. - drop(listener); - - // Wait for all tasks to complete. - debug!("waiting for {} tasks to finish", close_tx.receiver_count()); - close_tx.closed().await; + // Run the server with graceful shutdown + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .unwrap(); } async fn shutdown_signal() { From d9bb4df4757a14280953049346cd1be2ff801ba1 Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Sat, 2 Dec 2023 14:10:08 +0100 Subject: [PATCH 2/9] enable tokio's macros --- axum/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/axum/Cargo.toml b/axum/Cargo.toml index de388dddf1..237439d574 100644 --- a/axum/Cargo.toml +++ b/axum/Cargo.toml @@ -22,7 +22,7 @@ matched-path = [] multipart = ["dep:multer"] original-uri = [] query = ["dep:serde_urlencoded"] -tokio = ["dep:hyper-util", "dep:tokio", "tokio/net", "tokio/rt", "tower/make"] +tokio = ["dep:hyper-util", "dep:tokio", "tokio/net", "tokio/rt", "tower/make", "tokio/macros"] tower-log = ["tower/log"] tracing = ["dep:tracing", "axum-core/tracing"] ws = ["dep:hyper", "tokio", "dep:tokio-tungstenite", "dep:sha1", "dep:base64"] From 454be012b65dea8c33ac0c806f30844253a4c6a0 Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Sun, 3 Dec 2023 10:12:16 +0100 Subject: [PATCH 3/9] std::pin::pin! isn't on our MSRV --- axum/src/serve.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/axum/src/serve.rs b/axum/src/serve.rs index 38152283aa..e6755e936c 100644 --- a/axum/src/serve.rs +++ b/axum/src/serve.rs @@ -7,13 +7,13 @@ use std::{ io, marker::PhantomData, net::SocketAddr, - pin::{pin, Pin}, + pin::Pin, sync::Arc, task::{Context, Poll}, }; use axum_core::{body::Body, extract::Request, response::Response}; -use futures_util::{future::poll_fn, FutureExt}; +use futures_util::{future::poll_fn, pin_mut, FutureExt}; use hyper::body::Incoming; use hyper_util::{ rt::{TokioExecutor, TokioIo}, @@ -308,9 +308,10 @@ where tokio::spawn(async move { let builder = Builder::new(TokioExecutor::new()); let conn = builder.serve_connection_with_upgrades(tcp_stream, hyper_service); - let mut conn = pin!(conn); + pin_mut!(conn); - let mut signal_closed = pin!(signal_tx.closed().fuse()); + let signal_closed = signal_tx.closed().fuse(); + pin_mut!(signal_closed); loop { tokio::select! { From d57de75450d86f67c277cafa6af674eb49784150 Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Sun, 3 Dec 2023 10:20:42 +0100 Subject: [PATCH 4/9] why did the UI test output change?? --- .../tests/debug_handler/fail/argument_not_extractor.stderr | 2 +- .../tests/from_request/fail/parts_extracting_body.stderr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/axum-macros/tests/debug_handler/fail/argument_not_extractor.stderr b/axum-macros/tests/debug_handler/fail/argument_not_extractor.stderr index f33529e1a5..d946782586 100644 --- a/axum-macros/tests/debug_handler/fail/argument_not_extractor.stderr +++ b/axum-macros/tests/debug_handler/fail/argument_not_extractor.stderr @@ -13,8 +13,8 @@ error[E0277]: the trait bound `bool: FromRequestParts<()>` is not satisfied > > > - as FromRequestParts> > + as FromRequestParts> and $N others = note: required for `bool` to implement `FromRequest<(), axum_core::extract::private::ViaParts>` note: required by a bound in `__axum_macros_check_handler_0_from_request_check` diff --git a/axum-macros/tests/from_request/fail/parts_extracting_body.stderr b/axum-macros/tests/from_request/fail/parts_extracting_body.stderr index 3fb190a2a0..fbd58ea013 100644 --- a/axum-macros/tests/from_request/fail/parts_extracting_body.stderr +++ b/axum-macros/tests/from_request/fail/parts_extracting_body.stderr @@ -14,5 +14,5 @@ error[E0277]: the trait bound `String: FromRequestParts` is not satisfied > > > - as FromRequestParts> + > and $N others From 02499fc64d4a65830a6cf51258422a811af68cc5 Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Mon, 4 Dec 2023 09:19:30 +0100 Subject: [PATCH 5/9] use poll_fn from std --- axum/src/serve.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/axum/src/serve.rs b/axum/src/serve.rs index c1f0dcd494..4611f50b8c 100644 --- a/axum/src/serve.rs +++ b/axum/src/serve.rs @@ -3,7 +3,7 @@ use std::{ convert::Infallible, fmt::Debug, - future::{Future, IntoFuture}, + future::{Future, IntoFuture, poll_fn}, io, marker::PhantomData, net::SocketAddr, @@ -14,7 +14,7 @@ use std::{ }; use axum_core::{body::Body, extract::Request, response::Response}; -use futures_util::{future::poll_fn, pin_mut, FutureExt}; +use futures_util::{pin_mut, FutureExt}; use hyper::body::Incoming; use hyper_util::{ rt::{TokioExecutor, TokioIo}, From e31d41fb316967e31d076e7aed21297cd0b04929 Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Mon, 4 Dec 2023 09:23:28 +0100 Subject: [PATCH 6/9] handle accept errors in graceful shutdown --- axum/src/serve.rs | 62 ++++++++++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/axum/src/serve.rs b/axum/src/serve.rs index 4611f50b8c..d0d180f571 100644 --- a/axum/src/serve.rs +++ b/axum/src/serve.rs @@ -3,7 +3,7 @@ use std::{ convert::Infallible, fmt::Debug, - future::{Future, IntoFuture, poll_fn}, + future::{poll_fn, Future, IntoFuture}, io, marker::PhantomData, net::SocketAddr, @@ -169,30 +169,9 @@ where } = self; loop { - let (tcp_stream, remote_addr) = match tcp_listener.accept().await { - Ok(conn) => conn, - Err(e) => { - // Connection errors can be ignored directly, continue - // by accepting the next request. - if is_connection_error(&e) { - continue; - } - - // [From `hyper::Server` in 0.14](https://github.com/hyperium/hyper/blob/v0.14.27/src/server/tcp.rs#L186) - // - // > A possible scenario is that the process has hit the max open files - // > allowed, and so trying to accept a new connection will fail with - // > `EMFILE`. In some cases, it's preferable to just wait for some time, if - // > the application will likely close some files (or connections), and try - // > to accept the connection again. If this option is `true`, the error - // > will be logged at the `error` level, since it is still a big deal, - // > and then the listener will sleep for 1 second. - // - // hyper allowed customizing this but axum does not. - error!("accept error: {e}"); - tokio::time::sleep(Duration::from_secs(1)).await; - continue; - } + let (tcp_stream, remote_addr) = match tcp_accept(&tcp_listener).await { + Some(conn) => conn, + None => continue, }; let tcp_stream = TokioIo::new(tcp_stream); @@ -298,8 +277,11 @@ where private::ServeFuture(Box::pin(async move { loop { let (tcp_stream, remote_addr) = tokio::select! { - result = tcp_listener.accept() => { - result? + conn = tcp_accept(&tcp_listener) => { + match conn { + Some(conn) => conn, + None => continue, + } } _ = signal_tx.closed() => { trace!("signal received, not accepting new connections"); @@ -382,6 +364,32 @@ fn is_connection_error(e: &io::Error) -> bool { ) } +async fn tcp_accept(listener: &TcpListener) -> Option<(TcpStream, SocketAddr)> { + match listener.accept().await { + Ok(conn) => Some(conn), + Err(e) => { + if is_connection_error(&e) { + return None; + } + + // [From `hyper::Server` in 0.14](https://github.com/hyperium/hyper/blob/v0.14.27/src/server/tcp.rs#L186) + // + // > A possible scenario is that the process has hit the max open files + // > allowed, and so trying to accept a new connection will fail with + // > `EMFILE`. In some cases, it's preferable to just wait for some time, if + // > the application will likely close some files (or connections), and try + // > to accept the connection again. If this option is `true`, the error + // > will be logged at the `error` level, since it is still a big deal, + // > and then the listener will sleep for 1 second. + // + // hyper allowed customizing this but axum does not. + error!("accept error: {e}"); + tokio::time::sleep(Duration::from_secs(1)).await; + None + } + } +} + mod private { use std::{ future::Future, From 3d4986f49c2884cc2ff27cb40dfa45a9b10e1c7b Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Wed, 20 Dec 2023 21:14:31 +0100 Subject: [PATCH 7/9] use published version of hyper and hyper-util --- Cargo.toml | 4 ---- axum/Cargo.toml | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 17d2c4402b..a68aaab16a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,3 @@ default-members = ["axum", "axum-*"] # Example has been deleted, but README.md remains exclude = ["examples/async-graphql"] resolver = "2" - -[patch.crates-io] -hyper = { git = "https://github.com/hyperium/hyper", rev = "cf68ea902749e" } -hyper-util = { git = "https://github.com/hyperium/hyper-util", rev = "64f896695c0f1" } diff --git a/axum/Cargo.toml b/axum/Cargo.toml index 237439d574..f928a97a7f 100644 --- a/axum/Cargo.toml +++ b/axum/Cargo.toml @@ -53,8 +53,8 @@ tower-service = "0.3" # optional dependencies axum-macros = { path = "../axum-macros", version = "0.4.0", optional = true } base64 = { version = "0.21.0", optional = true } -hyper = { version = "1.0.0", optional = true } -hyper-util = { version = "0.1.1", features = ["tokio", "server", "server-auto"], optional = true } +hyper = { version = "1.1.0", optional = true } +hyper-util = { version = "0.1.2", features = ["tokio", "server", "server-auto"], optional = true } multer = { version = "2.0.0", optional = true } serde_json = { version = "1.0", features = ["raw_value"], optional = true } serde_path_to_error = { version = "0.1.8", optional = true } From a5761d80c3d7b081cdc133a382dd3789ed5e765b Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Fri, 29 Dec 2023 12:10:51 +0100 Subject: [PATCH 8/9] docs --- axum/src/serve.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/axum/src/serve.rs b/axum/src/serve.rs index d0d180f571..9850af2787 100644 --- a/axum/src/serve.rs +++ b/axum/src/serve.rs @@ -116,7 +116,27 @@ pub struct Serve { #[cfg(all(feature = "tokio", any(feature = "http1", feature = "http2")))] impl Serve { - /// TODO + /// Prepares a server to handle graceful shutdown when the provided future completes. + /// + /// # Example + /// + /// ``` + /// use axum::{Router, routing::get}; + /// + /// # async { + /// let router = Router::new().route("/", get(|| async { "Hello, World!" })); + /// + /// let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); + /// axum::serve(listener, router) + /// .with_graceful_shutdown(shutdown_signal()) + /// .await + /// .unwrap(); + /// # }; + /// + /// async fn shutdown_signal() { + /// // ... + /// } + /// ``` pub fn with_graceful_shutdown(self, signal: F) -> WithGracefulShutdown where F: Future + Send + 'static, From 11f5ea031395e8b3e79b22cd079943e6af3a3567 Mon Sep 17 00:00:00 2001 From: David Pedersen Date: Fri, 29 Dec 2023 12:14:08 +0100 Subject: [PATCH 9/9] changelog --- axum/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/axum/CHANGELOG.md b/axum/CHANGELOG.md index 1eb5499543..61bc8d8a9b 100644 --- a/axum/CHANGELOG.md +++ b/axum/CHANGELOG.md @@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **added:** `Body` implements `From<()>` now ([#2411]) - **change:** Update version of multer used internally for multipart ([#2433]) - **change:** Update tokio-tungstenite to 0.21 ([#2435]) +- **added:** Support graceful shutdown on `serve` ([#2398]) [#2411]: https://github.com/tokio-rs/axum/pull/2411 [#2433]: https://github.com/tokio-rs/axum/pull/2433 [#2435]: https://github.com/tokio-rs/axum/pull/2435 +[#2398]: https://github.com/tokio-rs/axum/pull/2398 # 0.7.2 (03. December, 2023)