diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f1e0b57..af4e5853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`run_stdio` registered its `SIGTERM`/`SIGINT` handler too late to catch a signal sent early in startup** — `serve_with` previously ran config validation, workspace-root heuristics, and `spawn_lsp_servers_background` (which spawns LSP child processes concurrently) before `run_stdio` ever registered a signal handler, and `run_stdio` itself only did so *after* `mcp_server.serve(..)`'s MCP `initialize` handshake resolved — `rmcp`'s `serve(..)` awaits the client's first message internally, so a signal arriving at any point up to and including that wait fell through to the OS's default disposition (immediate termination, skipping `Translator::shutdown_servers` and risking an orphaned LSP child process mid-spawn; the exact failure mode #270 was filed to prevent). New `ShutdownSignal` type is now constructed once, as the first statement in `serve_with`, before any of that startup work, and moved by value into whichever transport runs; `run_stdio` races it against the handshake itself, then reuses the same instance in its existing post-handshake `select!`. Also fixed a related gap introduced while designing the reused handle: `SIGINT` was previously re-registered via `tokio::signal::ctrl_c()` on every wait (a fresh one-shot listener each time), which silently lost a signal delivered while a different `select!` branch was being polled — `ShutdownSignal` now holds a persistent listener per signal kind (`SIGTERM` and `SIGINT` on Unix via `tokio::signal::unix::signal`, `Ctrl-C` on Windows via the persistent `tokio::signal::windows::ctrl_c()` stream) for its entire lifetime instead. (#318) - **`MCPLS_LOG_JSON`/`MCPLS_TRUST_PROJECT_CONFIG` rejected common boolean env-var spellings** — both flags are declared as plain `bool` fields with clap's `env` attribute, whose derived value parser is `str::parse::()`, accepting only the exact lowercase literals `"true"`/`"false"`. Any other common convention (`1`/`0`, `yes`/`no`, `Y`/`N`, `on`/`off`, uppercase `TRUE`/`FALSE`) caused clap to print a parse error and exit nonzero before `logging::init` or anything else ran — no LSP servers spawned, no MCP server started, nothing logged. A new `parse_bool_flag` value parser, applied to both fields via `#[arg(value_parser = ...)]`, now accepts `1`/`0`, `true`/`false`, `yes`/`no`, `y`/`n`, and `on`/`off`, case-insensitively; the `--log-json`/`--trust-project-config` CLI flags themselves are unaffected (still bare, no-value flags). Any other value is still a clear parse error at startup. (#295) - **mcpls did not exit on `SIGTERM`/`SIGINT` while a stdio MCP client kept its stdin write end open** — `rmcp::transport::stdio()` is backed by `tokio::io::stdin()`, which internally parks an uncancellable `spawn_blocking` thread in a raw `read()` syscall; `#[tokio::main]`'s generated wrapper blocks in `Runtime::drop` waiting for that thread once `main`'s body returns, even though all real shutdown work (LSP server teardown, log flush) had already completed by then. `main` now calls `std::process::exit` as its final step instead of returning normally, terminating immediately once `run().await` resolves and bypassing the blocking-pool wait. `main`'s signature changed from `-> std::process::ExitCode` (added in #279) to `()`; not a breaking change for library embedders — a binary's `main` signature is not part of `mcpls-core`'s public API, and the resulting exit codes (0/1) are unchanged. Scoped to `crates/mcpls-cli/src/main.rs` only — `mcpls-core`'s `serve`/`serve_with`/`shutdown`/`run_stdio` are unaffected and keep normal `Result`-returning semantics for library embedders; their doc comments now carry a "Shutdown" note (and updated examples) warning embedders of the same `process::exit` requirement under the stdio transport. Also fixed: `await_lsp_init_handle`'s timeout branch called `JoinHandle::abort()` without a subsequent await, so on a `SIGTERM` arriving mid-`spawn_batch` (before any server registers), `process::exit` could now run before the runtime dropped the aborted task's locals — including not-yet-registered LSP `Child` handles relying on `kill_on_drop` — orphaning those processes (the failure mode #270 guards against). `abort()` is now followed by a bounded (1s) re-await of the same handle so that drop happens before `main` exits. (#308) - **`workspace.position_encodings` was parsed but never consumed** — the configured preference order was previously dead: `LspServer::spawn`'s `initialize` handshake always offered a hardcoded `["utf-8", "utf-16"]` regardless of what `mcpls.toml` set. It is now sent as `capabilities.general.positionEncodings` in the configured order (see the accompanying breaking changes under Changed above). Note this only changes which encodings mcpls *offers*; the encoding a server actually negotiates is still not consumed downstream of the handshake (tracked separately in #290). (#287) diff --git a/crates/mcpls-core/src/lib.rs b/crates/mcpls-core/src/lib.rs index fa189ceb..073015c9 100644 --- a/crates/mcpls-core/src/lib.rs +++ b/crates/mcpls-core/src/lib.rs @@ -63,7 +63,7 @@ pub use transport::HttpConfig; pub use transport::Transport; #[cfg(feature = "transport-http")] use transport::run_http; -use transport::run_stdio; +use transport::{ShutdownSignal, run_stdio}; /// Whether `uri` falls within one of `workspace_roots`. /// @@ -467,6 +467,17 @@ pub async fn serve(config: ServerConfig) -> Result<(), Error> { pub async fn serve_with(config: ServerConfig, transport: Transport) -> Result<(), Error> { info!("Starting MCPLS server..."); + // Registered before any other startup work -- including + // `spawn_lsp_servers_background` below, which spawns LSP child processes + // concurrently on another worker thread -- so a `SIGTERM`/`SIGINT` + // arriving during config validation, workspace-root heuristics, or LSP + // spawning is caught rather than hitting the OS's default disposition + // (immediate termination, orphaning any LSP child mid-spawn; see #270) + // and skipping the `shutdown()` cleanup below entirely. See + // `ShutdownSignal`'s docs for why this must be a single instance carried + // through by value rather than re-registered later. + let shutdown_signal = ShutdownSignal::new(); + // `ServerConfig::load`/`load_from` already validate the TOML-loading // path; this covers the other one -- a caller building `ServerConfig` // programmatically (e.g. a library embedder) previously hit no @@ -615,10 +626,10 @@ pub async fn serve_with(config: ServerConfig, transport: Transport) -> Result<() let result = match transport { Transport::Stdio => { info!("Listening for MCP requests on stdio..."); - run_stdio(mcp_server, &peer_cell).await + run_stdio(mcp_server, &peer_cell, shutdown_signal).await } #[cfg(feature = "transport-http")] - Transport::Http(cfg) => run_http(mcp_server, cfg).await, + Transport::Http(cfg) => run_http(mcp_server, cfg, shutdown_signal).await, }; shutdown(&cancel_tx, &translator, lsp_init_handle).await; diff --git a/crates/mcpls-core/src/transport.rs b/crates/mcpls-core/src/transport.rs index 730e6393..c9571380 100644 --- a/crates/mcpls-core/src/transport.rs +++ b/crates/mcpls-core/src/transport.rs @@ -152,33 +152,130 @@ use rmcp::transport::streamable_http_server::session::{ ServerSseMessage, SessionId, SessionManager, }; -/// Waits for a shutdown signal: `SIGTERM` on Unix (as sent by containers and -/// systemd) or `Ctrl-C` (`SIGINT`) on any platform. +/// A registered handle for waiting on a shutdown signal: `SIGTERM`/`SIGINT` +/// on Unix (as sent by containers, systemd, and `Ctrl-C`) or `Ctrl-C` on +/// Windows. /// -/// Shared between [`run_stdio`] and [`run_http`] so both transports react to -/// the same signals the same way. -async fn wait_for_shutdown_signal() { +/// Constructed once by [`crate::serve_with`], *before* any startup work +/// (LSP-server discovery heuristics, `spawn_lsp_servers_background`) runs, +/// and moved by value into whichever transport (`run_stdio`/`run_http`) ends +/// up serving. Registering this early — rather than inside the transport +/// function itself — closes the startup window between process start and the +/// transport loop, during which a signal would otherwise hit the OS's +/// default disposition (immediate termination, bypassing +/// [`crate::bridge::Translator::shutdown_servers`] and risking an orphaned +/// LSP child process that `spawn_lsp_servers_background` is mid-spawning; +/// see #270). +/// +/// Every signal kind is held as its own persistent stream +/// (`tokio::signal::unix::Signal` / `tokio::signal::windows::CtrlC`) for the +/// lifetime of this value, rather than re-registered on every +/// [`ShutdownSignal::recv`] call via `tokio::signal::ctrl_c()`: a signal +/// delivered while a *specific* listener isn't being polled is only observed +/// by that same listener's next poll — a freshly (re-)subscribed one starts +/// at the broadcast's current version and never sees it (tokio +/// `signal/registry.rs`). Since [`recv`](ShutdownSignal::recv) is awaited +/// from more than one call site — both by [`run_stdio`], which races it +/// against the MCP handshake and then the post-handshake serve loop, and +/// across the gap between construction in `serve_with` and the first await +/// inside the transport — a fresh registration per call would risk losing a +/// signal delivered in between. +pub(crate) struct ShutdownSignal { #[cfg(unix)] - { - use tokio::signal::unix::{SignalKind, signal}; - match signal(SignalKind::terminate()) { - Ok(mut sigterm) => { - tokio::select! { - _ = tokio::signal::ctrl_c() => {}, - _ = sigterm.recv() => {}, + sigterm: Option, + #[cfg(unix)] + sigint: Option, + #[cfg(windows)] + ctrl_c: Option, +} + +impl ShutdownSignal { + /// Registers the process's shutdown signal handler(s) up front. + pub(crate) fn new() -> Self { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + let sigterm = match signal(SignalKind::terminate()) { + Ok(sigterm) => Some(sigterm), + Err(e) => { + tracing::warn!( + "SIGTERM handler registration failed ({e}), SIGTERM will not be caught" + ); + None + } + }; + let sigint = match signal(SignalKind::interrupt()) { + Ok(sigint) => Some(sigint), + Err(e) => { + tracing::warn!( + "SIGINT handler registration failed ({e}), SIGINT will not be caught" + ); + None + } + }; + Self { sigterm, sigint } + } + #[cfg(windows)] + { + let ctrl_c = match tokio::signal::windows::ctrl_c() { + Ok(ctrl_c) => Some(ctrl_c), + Err(e) => { + tracing::warn!("Ctrl-C handler registration failed ({e})"); + None + } + }; + Self { ctrl_c } + } + #[cfg(not(any(unix, windows)))] + { + Self {} + } + } + + /// Waits for the next shutdown signal. May be awaited repeatedly. + pub(crate) async fn recv(&mut self) { + #[cfg(unix)] + { + match (self.sigterm.as_mut(), self.sigint.as_mut()) { + (Some(sigterm), Some(sigint)) => { + tokio::select! { + _ = sigterm.recv() => {}, + _ = sigint.recv() => {}, + } + } + (Some(sigterm), None) => { + sigterm.recv().await; + } + (None, Some(sigint)) => { + sigint.recv().await; + } + (None, None) => { + // Both registrations failed above; fall back to a + // one-shot listener so shutdown is still possible, even + // though it doesn't carry the same across-calls + // durability the held streams above do (see the struct + // docs). + let _ = tokio::signal::ctrl_c().await; } } - Err(e) => { - tracing::warn!( - "SIGTERM handler registration failed ({e}), falling back to SIGINT only" - ); - let _ = tokio::signal::ctrl_c().await; + } + #[cfg(windows)] + { + match self.ctrl_c.as_mut() { + Some(ctrl_c) => { + ctrl_c.recv().await; + } + None => { + let _ = tokio::signal::ctrl_c().await; + } } } - } - #[cfg(not(unix))] - { - let _ = tokio::signal::ctrl_c().await; + #[cfg(not(any(unix, windows)))] + { + // No persistent listener is available on this platform; same + // caveat as the Unix double-registration-failure fallback above. + let _ = tokio::signal::ctrl_c().await; + } } } @@ -190,21 +287,36 @@ async fn wait_for_shutdown_signal() { /// the stdio transport closes (client disconnect / stdin EOF) or a `SIGTERM`/ /// `SIGINT` is received, so callers can run orderly cleanup — such as /// [`crate::bridge::Translator::shutdown_servers`] — before the process -/// exits. On signal, the in-flight `RunningService` is dropped rather than -/// awaited to completion; `rmcp` closes it asynchronously in that case, -/// which is acceptable here since the process exits shortly after -- -/// callers must exit via `std::process::exit` rather than returning -/// normally from `main`, or an uncancellable `tokio::io::stdin()` blocking -/// thread can stall runtime shutdown indefinitely (see `mcpls-cli`'s -/// `main.rs` and #308). +/// exits. +/// +/// `shutdown_signal` is constructed by [`crate::serve_with`] *before* any +/// startup work runs (see [`ShutdownSignal`]'s docs) and is raced here +/// against both the MCP handshake and, once it completes, the +/// post-handshake serve loop. `serve(..)` awaits the full MCP `initialize` +/// handshake internally (reading the client's request and writing the +/// response) before resolving, so a signal arriving during that wait — which +/// can be indefinite if the client is slow to send `initialize` — must be +/// caught there too, not only after the handshake finishes. On signal, the +/// in-flight handshake or `RunningService` is dropped rather than awaited to +/// completion; `rmcp` closes it asynchronously in that case, which is +/// acceptable here since the process exits shortly after -- callers must +/// exit via `std::process::exit` rather than returning normally from `main`, +/// or an uncancellable `tokio::io::stdin()` blocking thread can stall +/// runtime shutdown indefinitely (see `mcpls-cli`'s `main.rs` and #308). pub(crate) async fn run_stdio( mcp_server: crate::mcp::McplsServer, peer_cell: &tokio::sync::OnceCell>, + mut shutdown_signal: ShutdownSignal, ) -> Result<(), crate::Error> { - let service = mcp_server - .serve(rmcp::transport::stdio()) - .await - .map_err(|e| crate::Error::McpServer(format!("Failed to start MCP server: {e}")))?; + let service = tokio::select! { + result = mcp_server.serve(rmcp::transport::stdio()) => { + result.map_err(|e| crate::Error::McpServer(format!("Failed to start MCP server: {e}")))? + } + () = shutdown_signal.recv() => { + tracing::info!("shutdown signal received during handshake, stopping stdio transport"); + return Ok(()); + } + }; if let Err(e) = peer_cell.set(service.peer().clone()) { tracing::debug!("Peer cell already set ({}), ignoring", e); @@ -214,7 +326,7 @@ pub(crate) async fn run_stdio( result = service.waiting() => result .map(|_| ()) .map_err(|e| crate::Error::McpServer(format!("MCP server error: {e}"))), - () = wait_for_shutdown_signal() => { + () = shutdown_signal.recv() => { tracing::info!("shutdown signal received, stopping stdio transport"); Ok(()) } @@ -252,6 +364,10 @@ pub(crate) async fn run_stdio( /// regardless — bounding shutdown this way lets the caller run its own /// post-shutdown cleanup (e.g. closing registered LSP servers) even if a /// connection never observes the cancellation (a stuck SSE stream, say). +/// `shutdown_signal` is constructed by [`crate::serve_with`] before any +/// startup work runs (see [`ShutdownSignal`]'s docs), so its registration +/// predates this function's own `TcpListener::bind` call — a signal between +/// bind and the graceful-shutdown future's first poll is still caught. #[cfg(feature = "transport-http")] // `session_manager` and `service` are moved into `app`, which is served until // shutdown — clippy's drop-tightening heuristic misreads that as an @@ -261,6 +377,7 @@ pub(crate) async fn run_stdio( pub(crate) async fn run_http( mcp_server: crate::mcp::McplsServer, cfg: HttpConfig, + mut shutdown_signal: ShutdownSignal, ) -> Result<(), crate::Error> { use std::sync::Arc; @@ -310,7 +427,7 @@ pub(crate) async fn run_http( // which consumes its own clone. let cancel_for_force_timeout = cancel.clone(); let serve = axum::serve(listener, app).with_graceful_shutdown(async move { - wait_for_shutdown_signal().await; + shutdown_signal.recv().await; cancel.cancel(); }); @@ -595,7 +712,7 @@ mod tests { let outcome = tokio::time::timeout( std::time::Duration::from_secs(2), - super::run_stdio(server, &peer_cell), + super::run_stdio(server, &peer_cell, super::ShutdownSignal::new()), ) .await; @@ -699,7 +816,11 @@ mod tests { let cfg = HttpConfig::new(addr, "/mcp"); - let server_task = tokio::spawn(super::super::run_http(server, cfg)); + let server_task = tokio::spawn(super::super::run_http( + server, + cfg, + super::super::ShutdownSignal::new(), + )); tokio::time::sleep(std::time::Duration::from_millis(50)).await; // A successful TCP connect proves the listener is up. @@ -735,7 +856,11 @@ mod tests { drop(probe); let cfg = HttpConfig::new(addr, "/mcp"); - let server_task = tokio::spawn(super::super::run_http(test_server(), cfg)); + let server_task = tokio::spawn(super::super::run_http( + test_server(), + cfg, + super::super::ShutdownSignal::new(), + )); // Let the spawned task make initial progress (bind the // listener, enter its `select!`) without depending on any real @@ -787,7 +912,8 @@ mod tests { let cfg = HttpConfig::new(addr, "/mcp"); - let result = super::super::run_http(server, cfg).await; + let result = + super::super::run_http(server, cfg, super::super::ShutdownSignal::new()).await; assert!( result.is_err(), "run_http should fail when port is occupied" @@ -857,7 +983,11 @@ mod tests { drop(probe); let cfg = HttpConfig::new(addr, "/mcp").with_max_request_body_bytes(64); - let server_task = tokio::spawn(super::super::run_http(test_server(), cfg)); + let server_task = tokio::spawn(super::super::run_http( + test_server(), + cfg, + super::super::ShutdownSignal::new(), + )); tokio::time::sleep(std::time::Duration::from_millis(50)).await; let oversized_body = vec![b'a'; 65]; @@ -888,7 +1018,11 @@ mod tests { drop(probe); let cfg = HttpConfig::new(addr, "/mcp").with_max_request_body_bytes(64); - let server_task = tokio::spawn(super::super::run_http(test_server(), cfg)); + let server_task = tokio::spawn(super::super::run_http( + test_server(), + cfg, + super::super::ShutdownSignal::new(), + )); tokio::time::sleep(std::time::Duration::from_millis(50)).await; let small_body = vec![b'a'; 32]; @@ -1063,7 +1197,11 @@ mod tests { drop(probe); let cfg = HttpConfig::new(addr, "/mcp").with_max_concurrent_sessions(1); - let server_task = tokio::spawn(super::super::run_http(test_server(), cfg)); + let server_task = tokio::spawn(super::super::run_http( + test_server(), + cfg, + super::super::ShutdownSignal::new(), + )); tokio::time::sleep(std::time::Duration::from_millis(50)).await; let initialize_body = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}"#; @@ -1101,7 +1239,11 @@ mod tests { drop(probe); let cfg = HttpConfig::new(addr, "/mcp").with_max_concurrent_sessions(1); - let server_task = tokio::spawn(super::super::run_http(test_server(), cfg)); + let server_task = tokio::spawn(super::super::run_http( + test_server(), + cfg, + super::super::ShutdownSignal::new(), + )); tokio::time::sleep(std::time::Duration::from_millis(50)).await; let accept_headers = @@ -1179,7 +1321,7 @@ mod tests { // is enough to observe it. let _ = tokio::time::timeout( std::time::Duration::from_millis(200), - super::super::run_http(test_server(), cfg), + super::super::run_http(test_server(), cfg, super::super::ShutdownSignal::new()), ) .await; diff --git a/crates/mcpls-core/tests/e2e/protocol_tests.rs b/crates/mcpls-core/tests/e2e/protocol_tests.rs index 125c9748..835b9cc0 100644 --- a/crates/mcpls-core/tests/e2e/protocol_tests.rs +++ b/crates/mcpls-core/tests/e2e/protocol_tests.rs @@ -342,6 +342,14 @@ fn test_e2e_multiple_requests() -> Result<()> { /// `std::process::exit` fix in `mcpls-cli`'s `main`, `#[tokio::main]`'s /// runtime-shutdown wait for that thread would hang indefinitely as long as /// the client (this test, via `McpClient`) keeps stdin's write end open. +/// +/// Sending `SIGTERM` immediately after the handshake completes (no +/// artificial delay) also touches the tail of #318's window — the narrow gap +/// between `run_stdio`'s two `select!` blocks — but only weakly: signaling +/// this soon after `initialize()` returns reproduced the pre-fix bug in just +/// 1/15 runs, since the client-side I/O latency before the `kill` command +/// even runs dwarfs that gap. `test_e2e_sigterm_exits_promptly_during_handshake_wait` +/// below is the reliable reproducer for #318 (5/5 against pre-fix code). #[test] #[cfg(unix)] #[ignore = "Requires mcpls binary built"] @@ -349,16 +357,12 @@ fn test_e2e_sigterm_exits_promptly_while_client_stdin_open() -> Result<()> { let mut client = McpClient::spawn()?; client.initialize()?; - // `run_stdio` only registers its SIGTERM handler once `mcp_server.serve(..)` - // returns and its own `tokio::select!` is entered -- a short but real gap - // after the client's `initialize()` call already unblocks (empirically, - // long enough to consistently lose a signal sent with no delay at all). - // In real usage a client stays connected far longer than this before a - // `SIGTERM` arrives, so this sleep reproduces that realistic ordering - // instead of racing an unrelated startup window that has nothing to do - // with #308. - std::thread::sleep(std::time::Duration::from_millis(200)); - + // No delay here is intentional: `run_stdio` now registers its SIGTERM + // handler before awaiting the handshake at all (see #318), so the signal + // is raced against the handshake/select loop from the moment the + // process starts. Sending SIGTERM immediately after `initialize()` + // returns exercises the narrowest part of that window instead of + // masking it behind an artificial delay. let pid = client.pid(); let status = std::process::Command::new("kill") .args(["-TERM", &pid.to_string()]) @@ -393,3 +397,65 @@ fn test_e2e_sigterm_exits_promptly_while_client_stdin_open() -> Result<()> { std::thread::sleep(std::time::Duration::from_millis(50)); } } + +/// Test that mcpls exits promptly on `SIGTERM` sent *before* the client ever +/// sends the `initialize` request -- i.e. strictly during the MCP handshake +/// wait itself (regression test for #318). +/// +/// This targets the actual bug in #318 directly: pre-fix, `run_stdio` +/// registered its `SIGTERM` handler only *after* `mcp_server.serve(..)` +/// resolved, so any signal arriving while `serve(..)` was still awaiting the +/// client's `initialize` request -- which can be an arbitrarily long wait in +/// real usage -- fell through to the OS's default disposition (immediate +/// kill, no graceful shutdown, no LSP cleanup). Sending `SIGTERM` +/// immediately after spawning, before writing anything to the child's +/// stdin, reliably lands inside that wait rather than racing the much +/// narrower post-handshake gap that +/// `test_e2e_sigterm_exits_promptly_while_client_stdin_open` exercises. +#[test] +#[cfg(unix)] +#[ignore = "Requires mcpls binary built"] +fn test_e2e_sigterm_exits_promptly_during_handshake_wait() -> Result<()> { + let mut client = McpClient::spawn()?; + + // A brief sleep before signaling clears the unrelated, unfixable gap + // between `fork`/`exec` and the point where *any* process code (the + // runtime init that precedes even the fixed `ShutdownSignal::new()`) + // has run -- the OS applies the default disposition until then no + // matter what the binary does, so signaling with zero delay would fail + // even against the fix and wouldn't be exercising #318 at all. 50ms is + // far below the 5s deadline below and well within the handshake wait, + // since `initialize()` is deliberately never called: the child is left + // parked inside `mcp_server.serve(..)`, waiting to read the client's + // first request. + std::thread::sleep(std::time::Duration::from_millis(50)); + + let pid = client.pid(); + let status = std::process::Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .status()?; + assert!( + status.success(), + "failed to send SIGTERM to mcpls (pid {pid})" + ); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if let Some(exit_status) = client.try_wait()? { + assert_eq!( + exit_status.code(), + Some(0), + "mcpls should exit with status 0 via its own shutdown path even when SIGTERM \ + arrives before the MCP handshake completes, not be killed by the default \ + SIGTERM disposition (issue #318 regression)" + ); + return Ok(()); + } + assert!( + std::time::Instant::now() < deadline, + "mcpls did not exit within 5s of SIGTERM sent before the handshake completed \ + (issue #318 regression)" + ); + std::thread::sleep(std::time::Duration::from_millis(50)); + } +}