Description
run_stdio and shutdown (crates/mcpls-core/src/transport.rs, crates/mcpls-core/src/lib.rs) log a complete, correct shutdown sequence on SIGTERM/SIGINT -- "shutdown signal received", "Shutting down LSP servers...", "LSP server shut down successfully", "MCPLS server shutting down", "mcpls shutdown complete" -- and the spawned LSP child process (e.g. rust-analyzer) genuinely is killed. run() then returns Ok(()) and main() returns ExitCode::SUCCESS.
But if an MCP client is still connected over stdio (has not closed its write end of mcpls's stdin -- the normal state for any live session, e.g. Claude Desktop/Code, a long-running agent), the OS process itself does not exit. It was confirmed still running and consuming a PID more than 30 minutes after its own log said shutdown was complete; only SIGKILL (or the client itself eventually closing stdin) ends it.
Root cause, confirmed via sample(1) stack trace on macOS: the main thread is parked in tokio::runtime::blocking::pool::BlockingPool::shutdown -> Receiver::wait, which is the #[tokio::main]-generated wrapper blocking on Runtime::drop after main()'s body returned. That drop waits for every outstanding spawn_blocking task to finish. One tokio-rt-worker thread is still executing tokio::io::blocking::Blocking<std::io::Stdin>::poll_read -> std::io::Stdin::read -> the raw read() syscall on the process's real stdin fd -- a genuine blocking OS thread that tokio::io::stdin() uses internally (a documented tokio caveat: this thread cannot be cancelled and only returns when the fd sees more data or EOF). Since the client's write end is still open, that read() never returns, BlockingPool::shutdown never completes, and the process hangs indefinitely past the point where its own logs claim it already shut down.
This directly contradicts the doc comment on run_stdio: "Returns as soon as either the stdio transport closes ... or a SIGTERM/SIGINT is received, so callers can run orderly cleanup ... before the process exits ... which is acceptable here since the process exits shortly after." The process does not exit shortly after -- it exits only when the client disconnects, an event decoupled from the signal that was supposed to trigger shutdown.
It also undermines the stated purpose of #270/#241: wait_for_shutdown_signal's own doc comment says SIGTERM is "sent by containers and systemd." In that exact scenario -- an orchestrator restarting/stopping the mcpls container while its MCP client is still attached -- mcpls will not actually terminate within the orchestrator's grace period (commonly 10s for Docker/Kubernetes) and gets forcibly SIGKILLed, which is precisely the "orphans processes / unclean shutdown" outcome #270 was written to prevent. In this specific hang, LSP child cleanup already completed correctly before the hang (no orphaned rust-analyzer), so the practical blast radius is a hung/unkillable-by-SIGTERM parent process and delayed container/pod termination, not orphaned LSP subprocesses -- a narrower but still real regression of #270's goal.
The HTTP transport (run_http) is not affected by this specific mechanism -- it does not use tokio::io::stdin().
Reproduction Steps
- Build:
cargo build
- Start mcpls over stdio with its stdin connected to a pipe whose write end stays open (i.e. a real MCP client session, not
< /dev/null):
mkfifo /tmp/mcpls_stdin_fifo
exec 9<>/tmp/mcpls_stdin_fifo
./target/debug/mcpls --config <config.toml> <&9 > /tmp/out 2> /tmp/mcpls.log &
PID=$!
- Send a real
initialize request + notifications/initialized on fd 9 (completing the MCP handshake -- confirmed present in the log as "client initialized").
kill -TERM $PID
- Poll
ps -p $PID every second for 10+ seconds.
- Observe:
/tmp/mcpls.log shows the full shutdown sequence completing in well under a second ("mcpls shutdown complete"), but ps -p $PID still reports the process alive indefinitely (confirmed alive 30+ minutes later). A sample $PID 2 stack trace shows the main thread blocked in tokio::runtime::blocking::pool::BlockingPool::shutdown waiting on a worker thread stuck in a blocking read() on stdin.
- Only
kill -9 $PID, or delivering EOF on the client's write end, ends the process.
Expected Behavior
After SIGTERM/SIGINT, the mcpls process exits promptly (comparable to the logged shutdown duration), regardless of whether the connected client has closed its end of stdin. LSP subprocess cleanup (Translator::shutdown_servers()) must still complete before exit -- this part already works correctly today and must not regress.
Actual Behavior
The process hangs indefinitely in tokio runtime shutdown (BlockingPool::shutdown waiting on tokio::io::stdin()'s internal blocking-read thread) even after all application-level shutdown logging has completed. Only SIGKILL or the client itself closing stdin unblocks it.
Environment
- Version: 0.3.8, commit bc95b89
- Platform: macOS 26.6 (ARM64); root cause (tokio's internal stdin blocking-thread pool) is platform-independent, not yet independently re-confirmed on Linux
- Config: any config with at least one LSP server (reproduced with
rust-analyzer)
- Features: default (stdio transport)
Spec
.local/specs/009-sigterm-stdin-blocking-pool-hang/spec.md
Description
run_stdioandshutdown(crates/mcpls-core/src/transport.rs,crates/mcpls-core/src/lib.rs) log a complete, correct shutdown sequence onSIGTERM/SIGINT-- "shutdown signal received", "Shutting down LSP servers...", "LSP server shut down successfully", "MCPLS server shutting down", "mcpls shutdown complete" -- and the spawned LSP child process (e.g.rust-analyzer) genuinely is killed.run()then returnsOk(())andmain()returnsExitCode::SUCCESS.But if an MCP client is still connected over stdio (has not closed its write end of mcpls's stdin -- the normal state for any live session, e.g. Claude Desktop/Code, a long-running agent), the OS process itself does not exit. It was confirmed still running and consuming a PID more than 30 minutes after its own log said shutdown was complete; only
SIGKILL(or the client itself eventually closing stdin) ends it.Root cause, confirmed via
sample(1) stack trace on macOS: the main thread is parked intokio::runtime::blocking::pool::BlockingPool::shutdown->Receiver::wait, which is the#[tokio::main]-generated wrapper blocking onRuntime::dropaftermain()'s body returned. That drop waits for every outstandingspawn_blockingtask to finish. Onetokio-rt-workerthread is still executingtokio::io::blocking::Blocking<std::io::Stdin>::poll_read->std::io::Stdin::read-> the rawread()syscall on the process's real stdin fd -- a genuine blocking OS thread thattokio::io::stdin()uses internally (a documented tokio caveat: this thread cannot be cancelled and only returns when the fd sees more data or EOF). Since the client's write end is still open, thatread()never returns,BlockingPool::shutdownnever completes, and the process hangs indefinitely past the point where its own logs claim it already shut down.This directly contradicts the doc comment on
run_stdio: "Returns as soon as either the stdio transport closes ... or aSIGTERM/SIGINTis received, so callers can run orderly cleanup ... before the process exits ... which is acceptable here since the process exits shortly after." The process does not exit shortly after -- it exits only when the client disconnects, an event decoupled from the signal that was supposed to trigger shutdown.It also undermines the stated purpose of #270/#241:
wait_for_shutdown_signal's own doc comment saysSIGTERMis "sent by containers and systemd." In that exact scenario -- an orchestrator restarting/stopping the mcpls container while its MCP client is still attached -- mcpls will not actually terminate within the orchestrator's grace period (commonly 10s for Docker/Kubernetes) and gets forciblySIGKILLed, which is precisely the "orphans processes / unclean shutdown" outcome #270 was written to prevent. In this specific hang, LSP child cleanup already completed correctly before the hang (no orphanedrust-analyzer), so the practical blast radius is a hung/unkillable-by-SIGTERM parent process and delayed container/pod termination, not orphaned LSP subprocesses -- a narrower but still real regression of #270's goal.The HTTP transport (
run_http) is not affected by this specific mechanism -- it does not usetokio::io::stdin().Reproduction Steps
cargo build< /dev/null):initializerequest +notifications/initializedon fd 9 (completing the MCP handshake -- confirmed present in the log as "client initialized").kill -TERM $PIDps -p $PIDevery second for 10+ seconds./tmp/mcpls.logshows the full shutdown sequence completing in well under a second ("mcpls shutdown complete"), butps -p $PIDstill reports the process alive indefinitely (confirmed alive 30+ minutes later). Asample $PID 2stack trace shows the main thread blocked intokio::runtime::blocking::pool::BlockingPool::shutdownwaiting on a worker thread stuck in a blockingread()on stdin.kill -9 $PID, or delivering EOF on the client's write end, ends the process.Expected Behavior
After
SIGTERM/SIGINT, the mcpls process exits promptly (comparable to the logged shutdown duration), regardless of whether the connected client has closed its end of stdin. LSP subprocess cleanup (Translator::shutdown_servers()) must still complete before exit -- this part already works correctly today and must not regress.Actual Behavior
The process hangs indefinitely in tokio runtime shutdown (
BlockingPool::shutdownwaiting ontokio::io::stdin()'s internal blocking-read thread) even after all application-level shutdown logging has completed. OnlySIGKILLor the client itself closing stdin unblocks it.Environment
rust-analyzer)Spec
.local/specs/009-sigterm-stdin-blocking-pool-hang/spec.md