Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions crates/server/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::time::Duration;

use anyhow::{self, Error as AnyhowError};
use axum::Router;
use deployment::{Deployment, DeploymentError};
Expand Down Expand Up @@ -169,12 +171,12 @@ async fn main() -> Result<(), VibeKanbanError> {
let proxy_server = axum::serve(proxy_listener, proxy_router)
.with_graceful_shutdown(async move { proxy_shutdown.cancelled().await });

let main_handle = tokio::spawn(async move {
let mut main_handle = tokio::spawn(async move {
if let Err(e) = main_server.await {
tracing::error!("Main server error: {}", e);
}
});
let proxy_handle = tokio::spawn(async move {
let mut proxy_handle = tokio::spawn(async move {
if let Err(e) = proxy_server.await {
tracing::error!("Preview proxy error: {}", e);
}
Expand All @@ -186,12 +188,49 @@ async fn main() -> Result<(), VibeKanbanError> {
_ = shutdown_signal() => {
tracing::info!("Shutdown signal received");
}
_ = main_handle => {}
_ = proxy_handle => {}
_ = &mut main_handle => {}
_ = &mut proxy_handle => {}
}

shutdown_token.cancel();

// Bounded graceful shutdown.
//
// `axum::serve(...).with_graceful_shutdown(...)` waits for every in-flight
// connection to finish before dropping the listener. Long-lived streams
// (SSE, WebSocket, long-poll) do not drain on their own, so a single open
// stream can keep the listener socket alive indefinitely after the
// shutdown signal fires.
//
// On Windows, when the console delivers `CTRL_CLOSE_EVENT` (user closes
// the launcher window) the process is granted only ~5 seconds before
// `TerminateProcess` is invoked. If the listener is still alive at that
// point the AFD kernel state can linger under the dead PID, which blocks
// rebinding the same port until reboot.
//
// Give drain a bounded window; if it does not complete in time, abort
// the listener tasks so their sockets are dropped before exit.
const SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);

let drain = async {
let _ = (&mut main_handle).await;
let _ = (&mut proxy_handle).await;
};
if tokio::time::timeout(SHUTDOWN_DRAIN_TIMEOUT, drain)
.await
.is_err()
{
tracing::warn!(
"Graceful shutdown exceeded {:?}; aborting listeners",
SHUTDOWN_DRAIN_TIMEOUT
);
main_handle.abort();
proxy_handle.abort();
// Wait for abort to propagate so the listener sockets are dropped.
let _ = (&mut main_handle).await;
let _ = (&mut proxy_handle).await;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
}
Comment on lines +241 to +253

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the timeout elapses, the code aborts the listener tasks but then awaits both JoinHandles without any bound. If a task fails to observe cancellation promptly, shutdown can still hang past the intended 2s window (and potentially exceed the Windows ~5s close-event grace period). Consider adding a second (short) timeout around the post-abort() joins or otherwise ensuring this path is also time-bounded.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 1e62439 — post-abort wait is now wrapped in a second tokio::time::timeout(500ms). Worst-case total shutdown is now 2s (drain) + 0.5s (abort observe) = 2.5s, well inside the Windows CTRL_CLOSE ~5s grace window.


perform_cleanup_actions(&deployment).await;

Ok(())
Expand Down
Loading