Skip to content

Commit 2b92327

Browse files
committed
feat(websockets): coordinate chat server shutdown
1 parent 8470347 commit 2b92327

8 files changed

Lines changed: 108 additions & 23 deletions

File tree

‎Cargo.lock‎

Lines changed: 8 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎examples-common/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ edition.workspace = true
55
rust-version.workspace = true
66

77
[dependencies]
8+
err-report = { workspace = true }
89
rustls = { workspace = true }
10+
tokio = { workspace = true, features = ["rt", "signal", "macros"] }
11+
tokio-util = { workspace = true, features = ["rt"] }
912
tracing = { workspace = true }
1013
tracing-subscriber = { workspace = true }

‎examples-common/src/lib.rs‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,51 @@
1+
use std::io;
2+
3+
use err_report::Report;
4+
use tokio_util::sync::CancellationToken;
15
use tracing::level_filters::LevelFilter;
26
use tracing_subscriber::{EnvFilter, prelude::*};
37

8+
/// Spawns a task that cancels `shutdown` on SIGINT, SIGTERM, or SIGQUIT on Unix. On other
9+
/// platforms, listens for Ctrl-C.
10+
///
11+
/// Signal errors are logged with tracing and also cancel the token. The task is detached and stops
12+
/// when the Tokio runtime exits. Call this within a Tokio runtime.
13+
pub fn watch_shutdown_signal(shutdown: CancellationToken) {
14+
tokio::spawn(async move {
15+
let result: io::Result<()> = async {
16+
#[cfg(unix)]
17+
{
18+
use tokio::signal::unix::{SignalKind, signal};
19+
20+
let mut interrupt = signal(SignalKind::interrupt())?;
21+
let mut terminate = signal(SignalKind::terminate())?;
22+
let mut quit = signal(SignalKind::quit())?;
23+
24+
tokio::select! {
25+
_ = interrupt.recv() => {},
26+
_ = terminate.recv() => {},
27+
_ = quit.recv() => {},
28+
}
29+
30+
Ok(())
31+
}
32+
33+
#[cfg(not(unix))]
34+
tokio::signal::ctrl_c().await
35+
}
36+
.await;
37+
38+
if let Err(err) = result {
39+
tracing::error!(
40+
error = %Report::new(&err),
41+
"failed to listen for shutdown signals"
42+
);
43+
}
44+
45+
shutdown.cancel();
46+
});
47+
}
48+
449
/// Initializes standard tracing subscriber.
550
pub fn init_standard_logger() {
651
tracing_subscriber::registry()

‎websockets/chat/Cargo.toml‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ futures-util.workspace = true
1616
ractor = { version = "0.16", default-features = false, features = ["tokio_runtime"] }
1717
tokio = { workspace = true, features = ["rt-multi-thread", "time", "macros"] }
1818

19-
env_logger.workspace = true
20-
log.workspace = true
19+
tokio-util = { workspace = true, features = ["rt", "time"] }
20+
color-eyre.workspace = true
21+
eyre.workspace = true
22+
examples-common.workspace = true
23+
24+
tracing.workspace = true
2125
rand.workspace = true

‎websockets/chat/README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ cd websockets/chat
2828
cargo run --bin websocket-chat-server
2929
```
3030

31+
Press Ctrl-C to stop the server. On Unix, SIGINT, SIGTERM, and SIGQUIT start shutdown. The server
32+
closes WebSocket sessions with close code 1001 (Going Away). After the HTTP server finishes graceful
33+
shutdown, the chat actor stops.
34+
3135
## Browser Client
3236

3337
- Open in browser: <http://localhost:8080/>.

‎websockets/chat/src/main.rs‎

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ use actix_files::{Files, NamedFile};
1111
use actix_web::{
1212
App, Error, HttpRequest, HttpResponse, HttpServer, Responder, middleware::Logger, web,
1313
};
14+
use examples_common::{init_standard_logger, watch_shutdown_signal};
15+
use eyre::WrapErr as _;
1416
use ractor::{Actor, ActorRef};
17+
use tokio_util::sync::CancellationToken;
1518

1619
mod server;
1720
mod session;
@@ -24,11 +27,12 @@ async fn index() -> impl Responder {
2427
async fn chat_route(
2528
req: HttpRequest,
2629
stream: web::Payload,
27-
srv: web::Data<ActorRef<server::Command>>,
30+
web::ThinData(srv): web::ThinData<ActorRef<server::Command>>,
31+
web::ThinData(shutdown): web::ThinData<CancellationToken>,
2832
) -> Result<HttpResponse, Error> {
2933
let (response, session, messages) = actix_ws::handle(&req, stream)?;
3034

31-
tokio::task::spawn_local(session::chat_ws(srv.get_ref().clone(), session, messages));
35+
tokio::task::spawn_local(session::chat_ws(srv, session, messages, shutdown));
3236

3337
Ok(response)
3438
}
@@ -40,8 +44,9 @@ async fn get_count(count: web::Data<AtomicUsize>) -> impl Responder {
4044
}
4145

4246
#[tokio::main]
43-
async fn main() -> std::io::Result<()> {
44-
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
47+
async fn main() -> eyre::Result<()> {
48+
color_eyre::install()?;
49+
init_standard_logger();
4550

4651
// set up applications state
4752
// keep a count of the number of visitors
@@ -51,27 +56,37 @@ async fn main() -> std::io::Result<()> {
5156
let (server, server_task) =
5257
server::ChatServer::spawn(None, server::ChatServer, app_state.clone())
5358
.await
54-
.map_err(std::io::Error::other)?;
55-
let shutdown = server.clone();
59+
.wrap_err("failed to start chat actor")?;
60+
let actor = server.clone();
61+
let shutdown = CancellationToken::new();
62+
watch_shutdown_signal(shutdown.clone());
63+
let worker_shutdown = shutdown.clone();
5664

57-
log::info!("starting HTTP server at http://localhost:8080");
65+
tracing::info!("starting HTTP server at http://localhost:8080");
5866

59-
let result = HttpServer::new(move || {
67+
let http_server = HttpServer::new(move || {
6068
App::new()
6169
.app_data(web::Data::from(app_state.clone()))
62-
.app_data(web::Data::new(server.clone()))
70+
.app_data(web::ThinData(server.clone()))
71+
.app_data(web::ThinData(worker_shutdown.clone()))
6372
.service(web::resource("/").to(index))
6473
.route("/count", web::get().to(get_count))
6574
.route("/ws", web::get().to(chat_route))
6675
.service(Files::new("/static", "./static"))
6776
.wrap(Logger::default())
6877
})
78+
.shutdown_signal(shutdown.clone().cancelled_owned())
6979
.workers(2)
70-
.bind(("127.0.0.1", 8080))?
71-
.run()
72-
.await;
80+
.bind(("127.0.0.1", 8080));
81+
82+
let result = match http_server {
83+
Ok(http_server) => http_server.run().await.wrap_err("HTTP server failed"),
84+
Err(err) => Err(err).wrap_err("failed to bind HTTP server"),
85+
};
86+
87+
shutdown.cancel();
88+
actor.stop(None);
89+
server_task.await.wrap_err("chat actor task failed")?;
7390

74-
shutdown.stop(None);
75-
server_task.await.map_err(std::io::Error::other)?;
7691
result
7792
}

‎websockets/chat/src/server.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ impl Actor for ChatServer {
108108
) -> Result<(), ActorProcessingErr> {
109109
match message {
110110
Command::Connect { addr, reply } => {
111-
println!("Someone joined");
111+
tracing::info!("Someone joined");
112112

113113
// notify all users in same room
114114
state.send_message("main", "Someone joined", 0);
@@ -127,7 +127,7 @@ impl Actor for ChatServer {
127127
let _ = reply.send(id);
128128
}
129129
Command::Disconnect { id } => {
130-
println!("Someone disconnected");
130+
tracing::info!("Someone disconnected");
131131

132132
let mut rooms: Vec<String> = Vec::new();
133133

‎websockets/chat/src/session.rs‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
//! WebSocket connection handling and chat command parsing.
22
//!
33
//! Aggregates fragmented messages, checks heartbeats, and forwards chat messages through the actor.
4-
//! Removes the session from the actor when the connection ends.
4+
//! Closes connections on shutdown and removes each session from the actor when it ends.
55
66
use std::time::{Duration, Instant};
77

88
use actix_ws::AggregatedMessage;
99
use futures_util::StreamExt as _;
1010
use ractor::{ActorProcessingErr, ActorRef};
1111
use tokio::sync::mpsc;
12+
use tokio_util::{future::FutureExt as _, sync::CancellationToken};
1213

1314
use crate::server::Command;
1415

@@ -20,6 +21,7 @@ pub async fn chat_ws(
2021
server: ActorRef<Command>,
2122
mut session: actix_ws::Session,
2223
messages: actix_ws::MessageStream,
24+
shutdown: CancellationToken,
2325
) {
2426
let (addr, mut inbox) = mpsc::unbounded_channel();
2527
let Ok(id) = ractor::call!(server, |reply| Command::Connect { addr, reply }) else {
@@ -39,6 +41,8 @@ pub async fn chat_ws(
3941

4042
let reason = loop {
4143
tokio::select! {
44+
_ = shutdown.cancelled() => break Some(actix_ws::CloseCode::Away.into()),
45+
4246
incoming = messages.next() => {
4347
match incoming {
4448
Some(Ok(AggregatedMessage::Ping(bytes))) => {
@@ -52,16 +56,18 @@ pub async fn chat_ws(
5256
}
5357
}
5458
Some(Ok(AggregatedMessage::Close(reason))) => break reason,
55-
Some(Ok(AggregatedMessage::Binary(_))) => log::warn!("unexpected binary message"),
59+
Some(Ok(AggregatedMessage::Binary(_))) => tracing::warn!("unexpected binary message"),
5660
Some(Err(_)) | None => break None,
5761
}
5862
}
63+
5964
outgoing = inbox.recv() => {
6065
match outgoing {
6166
Some(text) => if session.text(text).await.is_err() { break None; },
6267
None => break None,
6368
}
6469
}
70+
6571
_ = timer.tick() => {
6672
if heartbeat.elapsed() > CLIENT_TIMEOUT || session.ping(b"").await.is_err() {
6773
break None;
@@ -71,7 +77,9 @@ pub async fn chat_ws(
7177
};
7278

7379
let _ = server.cast(Command::Disconnect { id });
74-
let _ = session.close(reason).await;
80+
81+
// A slow peer must not hold up shutdown while the close frame is queued.
82+
let _ = session.close(reason).timeout(Duration::from_secs(1)).await;
7583
}
7684

7785
async fn process_text(

0 commit comments

Comments
 (0)