Skip to content

Commit da0262c

Browse files
committed
fix test
Signed-off-by: kerthcet <kerthcet@gmail.com>
1 parent 9485097 commit da0262c

3 files changed

Lines changed: 170 additions & 4 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sandd/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ filetime = "0.2"
7878
[dev-dependencies]
7979
tempfile = "3.8"
8080
criterion = { version = "0.5", features = ["async_tokio"] }
81+
# Used only by shutdown tests to raise SIGTERM to our own process.
82+
libc = "0.2"
8183

8284
[[bench]]
8385
name = "snapshot_bench"

sandd/src/main.rs

Lines changed: 167 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use tracing::{debug, error, info, warn};
2323
const TUNNEL_SOCKS_PROXY: &str = "127.0.0.1:1055";
2424

2525
/// Why the serve loop returned, so main() knows whether to reconnect or exit.
26+
#[derive(Debug, PartialEq, Eq)]
2627
enum ServeOutcome {
2728
/// The connection dropped (server closed, socket error). main() reconnects.
2829
Disconnected,
@@ -220,7 +221,7 @@ async fn connect_and_serve(
220221
.await
221222
.context("tunnel: WebSocket handshake over SOCKS5 failed")?;
222223
log_negotiated_protocol(&response);
223-
return serve(ws_stream, daemon_id, heartbeat_interval, labels).await;
224+
return serve(ws_stream, daemon_id, heartbeat_interval, labels, shutdown_signal()).await;
224225
}
225226

226227
let (ws_stream, response) = match tokio_tungstenite::connect_async(request).await {
@@ -231,7 +232,7 @@ async fn connect_and_serve(
231232
}
232233
};
233234
log_negotiated_protocol(&response);
234-
serve(ws_stream, daemon_id, heartbeat_interval, labels).await
235+
serve(ws_stream, daemon_id, heartbeat_interval, labels, shutdown_signal()).await
235236
}
236237

237238
/// Log the WebSocket subprotocol the server negotiated (shared by both transports).
@@ -248,14 +249,21 @@ fn log_negotiated_protocol(
248249
/// Run the daemon session over an established WebSocket stream. Generic over the
249250
/// transport so the direct (connect_async) and tunnel (SOCKS5) paths share one
250251
/// implementation.
251-
async fn serve<S>(
252+
///
253+
/// `shutdown` is the future that, once resolved, triggers a graceful close: in
254+
/// production it is `shutdown_signal()` (SIGTERM/SIGINT); tests inject a future
255+
/// they control so the shutdown path can be exercised without raising a real,
256+
/// process-wide signal mid-connection.
257+
async fn serve<S, F>(
252258
ws_stream: tokio_tungstenite::WebSocketStream<S>,
253259
daemon_id: &str,
254260
heartbeat_interval: u64,
255261
labels: HashMap<String, String>,
262+
shutdown: F,
256263
) -> Result<ServeOutcome>
257264
where
258265
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
266+
F: std::future::Future<Output = ()>,
259267
{
260268
info!("WebSocket connection established");
261269

@@ -334,6 +342,10 @@ where
334342
// controller removes a daemon the moment it sees that Close (server.rs
335343
// handle_websocket), so a graceful pod deletion deregisters immediately
336344
// instead of waiting out the ~90s heartbeat-timeout reaper.
345+
//
346+
// Pin the shutdown future once so it can be polled across loop iterations
347+
// without being moved (it may be `!Unpin`).
348+
tokio::pin!(shutdown);
337349
let outcome = loop {
338350
tokio::select! {
339351
// Prefer draining inbound messages; the signal branch still fires
@@ -380,7 +392,7 @@ where
380392
}
381393
}
382394

383-
_ = shutdown_signal() => {
395+
_ = &mut shutdown => {
384396
// Best-effort clean close so the controller deregisters us now.
385397
let mut tx = ws_tx_clone.lock().await;
386398
if let Err(e) = tx.send(WsMessage::Close(None)).await {
@@ -807,3 +819,154 @@ async fn setup_tunnel(args: &Args) -> Result<()> {
807819

808820
Err(anyhow::anyhow!("Timeout waiting for mesh IP assignment"))
809821
}
822+
823+
#[cfg(test)]
824+
mod shutdown_tests {
825+
//! Tests for the graceful-shutdown path added to `serve`: on a shutdown
826+
//! signal the daemon must send a WebSocket Close frame and return
827+
//! `ServeOutcome::Shutdown` (so `main` exits instead of reconnecting). The
828+
//! Close is what lets the controller deregister the daemon immediately
829+
//! rather than waiting out its ~90s heartbeat-timeout reaper.
830+
//!
831+
//! `serve` takes the shutdown future as a parameter precisely so these tests
832+
//! can trigger it deterministically, without raising a real, process-wide
833+
//! SIGTERM in the middle of a test run.
834+
835+
use super::*;
836+
use futures_util::{SinkExt, StreamExt};
837+
use tokio::net::{TcpListener, TcpStream};
838+
use tokio_tungstenite::tungstenite::protocol::Message as WsMessage;
839+
use tokio_tungstenite::{accept_async, WebSocketStream};
840+
841+
/// Stand up an in-process WebSocket server on localhost and connect a client
842+
/// to it. Returns (client_stream_for_serve, accepted_server_stream). The
843+
/// server side lets a test act as the controller: ack registration, then
844+
/// observe what the daemon sends (e.g. the Close frame on shutdown).
845+
async fn ws_pair() -> (
846+
WebSocketStream<tokio_tungstenite::MaybeTlsStream<TcpStream>>,
847+
WebSocketStream<TcpStream>,
848+
) {
849+
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
850+
let addr = listener.local_addr().unwrap();
851+
852+
// Accept concurrently with the client dial so neither side blocks.
853+
let server = tokio::spawn(async move {
854+
let (stream, _) = listener.accept().await.unwrap();
855+
accept_async(stream).await.unwrap()
856+
});
857+
858+
let url = format!("ws://{}/ws", addr);
859+
let (client, _resp) = tokio_tungstenite::connect_async(url).await.unwrap();
860+
let server = server.await.unwrap();
861+
(client, server)
862+
}
863+
864+
/// Play the controller: read the daemon's Register and reply RegisterAck so
865+
/// `serve` proceeds past registration into its main loop.
866+
async fn ack_registration(server: &mut WebSocketStream<TcpStream>) {
867+
let reg = server.next().await.unwrap().unwrap();
868+
let text = reg.into_text().unwrap();
869+
let msg: Message = serde_json::from_str(&text).unwrap();
870+
assert!(
871+
matches!(msg, Message::Register { .. }),
872+
"expected Register first, got: {:?}",
873+
msg
874+
);
875+
let ack = Message::RegisterAck {
876+
success: true,
877+
message: "ok".to_string(),
878+
};
879+
server
880+
.send(WsMessage::Text(serde_json::to_string(&ack).unwrap()))
881+
.await
882+
.unwrap();
883+
}
884+
885+
/// The core regression: when the shutdown future fires, `serve` returns
886+
/// `Shutdown` AND the peer receives a Close frame.
887+
///
888+
/// `serve`'s future is not `Send` (SessionManager is !Sync), so it can't be
889+
/// `tokio::spawn`ed; instead we run it concurrently with the controller side
890+
/// via `join!` on the current task.
891+
#[tokio::test]
892+
async fn shutdown_sends_close_and_returns_shutdown() {
893+
let (client, mut server) = ws_pair().await;
894+
895+
// Fire shutdown shortly after serve starts its loop. A ready future
896+
// would race registration; a short delay keeps the test deterministic
897+
// without depending on wall-clock timing for correctness.
898+
let shutdown = async {
899+
tokio::time::sleep(Duration::from_millis(50)).await;
900+
};
901+
902+
let daemon = serve(client, "test-daemon", 3600, HashMap::new(), shutdown);
903+
904+
let controller = async {
905+
ack_registration(&mut server).await;
906+
// The controller side should observe a Close frame. Tolerate any
907+
// pre-close traffic (e.g. a heartbeat), though the 3600s interval
908+
// makes that unlikely in-test.
909+
let mut saw_close = false;
910+
while let Some(frame) = server.next().await {
911+
match frame {
912+
Ok(WsMessage::Close(_)) => {
913+
saw_close = true;
914+
break;
915+
}
916+
Ok(_) => continue,
917+
Err(_) => break,
918+
}
919+
}
920+
saw_close
921+
};
922+
923+
let (outcome, saw_close) = tokio::join!(daemon, controller);
924+
assert!(saw_close, "daemon did not send a Close frame on shutdown");
925+
assert_eq!(outcome.unwrap(), ServeOutcome::Shutdown);
926+
}
927+
928+
/// The counterpart: if the controller closes the connection, `serve` returns
929+
/// `Disconnected` (so `main` reconnects) — NOT `Shutdown`. Guards against the
930+
/// shutdown branch swallowing ordinary disconnects.
931+
#[tokio::test]
932+
async fn server_close_returns_disconnected() {
933+
let (client, mut server) = ws_pair().await;
934+
935+
// A shutdown future that never resolves: only the server-close path can
936+
// end this session.
937+
let shutdown = std::future::pending::<()>();
938+
939+
let daemon = serve(client, "test-daemon", 3600, HashMap::new(), shutdown);
940+
941+
let controller = async {
942+
ack_registration(&mut server).await;
943+
// Controller closes the connection.
944+
server.close(None).await.unwrap();
945+
};
946+
947+
let (outcome, ()) = tokio::join!(daemon, controller);
948+
assert_eq!(outcome.unwrap(), ServeOutcome::Disconnected);
949+
}
950+
951+
/// `shutdown_signal()` must resolve when the process receives SIGTERM (what
952+
/// `kubectl delete pod` / `docker stop` send). Uses a real self-signal; unix
953+
/// only. This asserts the wiring, not the WebSocket behavior above.
954+
#[cfg(unix)]
955+
#[tokio::test]
956+
async fn shutdown_signal_resolves_on_sigterm() {
957+
// Raise SIGTERM to our own process after a short delay, then confirm the
958+
// helper's future completes rather than hanging.
959+
tokio::spawn(async {
960+
tokio::time::sleep(Duration::from_millis(50)).await;
961+
// SAFETY: raising a signal to our own PID is sound; kill(2) with a
962+
// valid signal number has no memory-safety implications.
963+
unsafe {
964+
libc::raise(libc::SIGTERM);
965+
}
966+
});
967+
968+
tokio::time::timeout(Duration::from_secs(5), shutdown_signal())
969+
.await
970+
.expect("shutdown_signal did not resolve on SIGTERM");
971+
}
972+
}

0 commit comments

Comments
 (0)