Skip to content

fix(server): handle graceful process shutdown - #294

Merged
nachiketb-nvidia merged 3 commits into
mainfrom
fix/server-graceful-shutdown
Aug 4, 2026
Merged

fix(server): handle graceful process shutdown#294
nachiketb-nvidia merged 3 commits into
mainfrom
fix/server-graceful-shutdown

Conversation

@nachiketb-nvidia

@nachiketb-nvidia nachiketb-nvidia commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What

  • handle Ctrl+C and Unix SIGTERM in the Rust server binary
  • apply one configurable drain deadline to HTTP and TLS servers
  • expose --shutdown-timeout, defaulting to 30 seconds

Why

Docker and Kubernetes terminate processes with SIGTERM. The server previously handled only Ctrl+C, plain HTTP could drain forever, and TLS allowed only two seconds. That could either drop telemetry and active requests immediately or block shutdown indefinitely.

How

Both transports now use the existing axum-server graceful-shutdown handle. The signal future remains a small Tokio wrapper because SIGTERM is Unix-specific; embedded callers can continue supplying their own shutdown future and deadline.

What to review

  • signal behavior in shutdown_signal
  • the shared bounded drain path in BoundServer::serve
  • the 30-second CLI default versus the preserved two-second Python binding contract

Validation

  • cargo test -p switchyard-server
  • cargo clippy -p switchyard-server --all-targets -- -D warnings
  • cargo check -p switchyard-py
  • live SIGTERM run: server logged the drain and exited with status 0

Summary by CodeRabbit

  • New Features

    • Added graceful server shutdown with configurable request-draining time.
    • Added --shutdown-timeout, defaulting to 30 seconds.
    • Shutdown now responds to Ctrl+C and Unix SIGTERM, allowing active requests to complete before stopping.
  • Bug Fixes

    • Added enforcement of shutdown deadlines so requests do not block server termination indefinitely.
  • Documentation

    • Documented graceful shutdown behavior and timeout configuration.

Signed-off-by: nachiketb <nachiketb@nvidia.com>
@nachiketb-nvidia
nachiketb-nvidia requested a review from a team as a code owner August 4, 2026 22:07
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

Graceful shutdown

Layer / File(s) Summary
Shutdown timeout contract
crates/switchyard-server/src/lib.rs, crates/switchyard-server/src/cli.rs, crates/switchyard-py/src/server_bindings.rs
Adds a 30-second default, the --shutdown-timeout option, runtime propagation through ServerRunOptions, and a two-second Python binding value.
Graceful shutdown runtime
crates/switchyard-server/src/lib.rs, crates/switchyard-server/README.md
HTTP and TLS serving use configurable axum_server handles. Ctrl+C and Unix SIGTERM initiate request draining. Integration tests cover completion and timeout enforcement. The README documents the behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Poem

I’m a rabbit guarding the server door,
New requests pause while old ones explore.
Ctrl+C thumps, SIGTERM rings,
Two seconds for Python’s gentle wings.
Thirty seconds let requests rest,
Then timeout hops in to end the quest.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the pull request's main change: graceful process shutdown handling in the server.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
crates/switchyard-server/src/lib.rs (2)

311-341: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Schedule the shutdown task after the fallible listener setup.

In both serve and serve_tls, schedule_shutdown spawns the task before the fallible calls that follow it. If listener.into_std() fails, or if axum_server::from_tcp_rustls / from_tcp fails, the function returns early through ? and never reaches shutdown_task.abort(). The spawned task then stays alive for the process lifetime, awaiting a shutdown future that will call graceful_shutdown on a handle whose server never started.

Move the listener conversion and builder construction ahead of schedule_shutdown so every error path returns before a task is spawned.

Also add a short comment on schedule_shutdown. It carries the shutdown lifecycle contract for both transports, and the guideline asks for comments on non-obvious private helpers and important async and lifecycle behavior.

♻️ Proposed reordering for the non-TLS path
 async fn serve(
     listener: TcpListener,
     router: Router,
     shutdown_timeout: Duration,
     shutdown: impl Future<Output = ()> + Send + 'static,
 ) -> ServerResult<()> {
-    let handle = axum_server::Handle::new();
-    let shutdown_task = schedule_shutdown(handle.clone(), shutdown_timeout, shutdown);
     let std_listener = listener.into_std().map_err(server_io_error)?;
-    let result = axum_server::from_tcp(std_listener)
-        .map_err(server_io_error)?
+    let server = axum_server::from_tcp(std_listener).map_err(server_io_error)?;
+    let handle = axum_server::Handle::new();
+    let shutdown_task = schedule_shutdown(handle.clone(), shutdown_timeout, shutdown);
+    let result = server
         .handle(handle)
         .serve(router.into_make_service())
         .await
         .map_err(server_io_error);
     shutdown_task.abort();
     result
 }

Apply the same reordering in serve_tls, moving RustlsConfig::from_pem_file, listener.into_std(), and axum_server::from_tcp_rustls before schedule_shutdown.

+/// Aborts nothing on its own: the caller must abort the returned task once the
+/// server stops, otherwise the task outlives the server it was created for.
 fn schedule_shutdown(
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-server/src/lib.rs` around lines 311 - 341, In both serve
and serve_tls, complete all fallible configuration, listener conversion, and
axum_server builder construction before calling schedule_shutdown, then create
the shutdown task only after those steps succeed and retain the existing abort
after serving. Add a brief comment on schedule_shutdown documenting its shared
transport shutdown lifecycle contract and async behavior.

Source: Coding guidelines


1196-1240: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Widen the timing margins and split the two scenarios.

Both scenarios depend on real wall-clock timing under #[tokio::test]. Two margins are tight enough to flake on a loaded CI machine:

  • Line 1207: a 25 ms probe asserts the server has not finished. A scheduling stall that delays the shutdown task past 25 ms is not distinguishable from correct draining, so the assertion can fail spuriously.
  • Line 1230: a 25 ms grace period is close to the runtime's own scheduling jitter, so the second scenario can pass without proving the deadline was enforced.

Increase the separation between the grace period and the probe window. Use a multi-second grace period with a sub-second probe in the first scenario, and a grace period of a few hundred milliseconds in the second.

Also split the function into two tests, one per scenario. A failure then names the behavior that broke instead of one shared test name.

♻️ Proposed margin change
-        } = shutdown_test_server(Duration::from_secs(1));
+        } = shutdown_test_server(Duration::from_secs(10));
         state.started.notified().await;
         shutdown.send(()).expect("server receives shutdown");
         assert!(
-            tokio::time::timeout(Duration::from_millis(25), &mut server)
+            tokio::time::timeout(Duration::from_millis(250), &mut server)
                 .await
                 .is_err(),
             "server must wait for the active request"
         );
-        } = shutdown_test_server(Duration::from_millis(25));
+        } = shutdown_test_server(Duration::from_millis(250));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-server/src/lib.rs` around lines 1196 - 1240, Update the
shutdown tests by splitting shutdown_drains_until_configured_deadline into two
independently named tests, one covering request draining and one covering
deadline enforcement. In the draining test, use a multi-second configured grace
period and a sub-second timeout for the pre-release probe; in the deadline test,
use a grace period of a few hundred milliseconds while retaining the bounded
server completion assertion. Preserve each scenario’s existing request, release,
and response assertions.
crates/switchyard-py/src/server_bindings.rs (1)

49-49: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the drain deadline from DEFAULT_SHUTDOWN_TIMEOUT_SECS.

Line 21 already defines DEFAULT_SHUTDOWN_TIMEOUT_SECS: f64 = 2.0, and close and __exit__ use it as the caller's wait budget. Line 49 repeats the same two-second value as a separate literal. The two values now encode one contract in two places, so a later change to one leaves the other stale.

Note also that the two deadlines are equal. close starts its wait, the server then begins draining, and the server drain can reach its own deadline at the same moment close gives up. A drain deadline strictly shorter than the caller's wait budget makes close deterministic. If a behavior change is out of scope for this PR, keep the value and only remove the duplication.

♻️ Proposed deduplication
-                    shutdown_timeout: Duration::from_secs(2),
+                    shutdown_timeout: Duration::from_secs_f64(DEFAULT_SHUTDOWN_TIMEOUT_SECS),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-py/src/server_bindings.rs` at line 49, Update the server
shutdown configuration near `shutdown_timeout` to derive its duration from
`DEFAULT_SHUTDOWN_TIMEOUT_SECS` instead of duplicating the two-second literal.
Preserve the current timeout value and behavior; only remove the duplicated
constant usage unless an existing shorter drain-timeout convention is already
defined.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/switchyard-py/src/server_bindings.rs`:
- Line 49: Update the server shutdown configuration near `shutdown_timeout` to
derive its duration from `DEFAULT_SHUTDOWN_TIMEOUT_SECS` instead of duplicating
the two-second literal. Preserve the current timeout value and behavior; only
remove the duplicated constant usage unless an existing shorter drain-timeout
convention is already defined.

In `@crates/switchyard-server/src/lib.rs`:
- Around line 311-341: In both serve and serve_tls, complete all fallible
configuration, listener conversion, and axum_server builder construction before
calling schedule_shutdown, then create the shutdown task only after those steps
succeed and retain the existing abort after serving. Add a brief comment on
schedule_shutdown documenting its shared transport shutdown lifecycle contract
and async behavior.
- Around line 1196-1240: Update the shutdown tests by splitting
shutdown_drains_until_configured_deadline into two independently named tests,
one covering request draining and one covering deadline enforcement. In the
draining test, use a multi-second configured grace period and a sub-second
timeout for the pre-release probe; in the deadline test, use a grace period of a
few hundred milliseconds while retaining the bounded server completion
assertion. Preserve each scenario’s existing request, release, and response
assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: a62ca03b-ec5a-49ee-bbf9-fa1ad5b4b113

📥 Commits

Reviewing files that changed from the base of the PR and between a3283c9 and ead2dcc.

📒 Files selected for processing (4)
  • crates/switchyard-py/src/server_bindings.rs
  • crates/switchyard-server/README.md
  • crates/switchyard-server/src/cli.rs
  • crates/switchyard-server/src/lib.rs

Signed-off-by: nachiketb <nachiketb@nvidia.com>
Comment thread crates/switchyard-server/src/lib.rs Outdated
Signed-off-by: nachiketb <nachiketb@nvidia.com>
@nachiketb-nvidia
nachiketb-nvidia enabled auto-merge (squash) August 4, 2026 22:37
@nachiketb-nvidia
nachiketb-nvidia merged commit c1c1b41 into main Aug 4, 2026
17 checks passed
@nachiketb-nvidia
nachiketb-nvidia deleted the fix/server-graceful-shutdown branch August 4, 2026 22:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants