Description
run_http (crates/mcpls-core/src/transport.rs:120-196) installs SIGTERM/SIGINT handlers and drives axum::serve(...).with_graceful_shutdown(...), explicitly calling cancel.cancel() on signal receipt so the HTTP transport shuts down cleanly. run_stdio (crates/mcpls-core/src/transport.rs:84-102), which is the default transport used by every stdio-based MCP client (Claude Desktop, Claude Code, etc.), installs no signal handler at all — it just awaits service.waiting().
Every spawned LSP child process (LspServer::spawn, crates/mcpls-core/src/lsp/lifecycle.rs:232-238) relies on tokio::process::Command::kill_on_drop(true) to be reaped when mcpls exits. kill_on_drop only fires when the Child's Drop runs, i.e. during normal unwinding as main() returns. On Unix, an uncaught SIGINT/SIGTERM terminates the process immediately without running destructors (this is the default signal disposition, bypassed only if a handler is registered — which run_http does and run_stdio does not). The workspace also sets panic = "abort" (Cargo.toml:60), so any reachable panic has the same effect.
Separately, LspServer::shutdown() (crates/mcpls-core/src/lsp/lifecycle.rs:445-459), which sends the graceful LSP shutdown request + exit notification, is never called from serve_with or any production path — only from unit tests. So even on the clean-exit path (stdin EOF), spawned LSP servers are only ever killed, never given the LSP-level chance to shut down gracefully.
Reproduction Steps
- Run
mcpls directly in a terminal (stdio mode, no --listen) with a configured LSP server (e.g. rust-analyzer) in a workspace.
- Confirm the LSP server process is running (
ps aux | grep rust-analyzer).
- Send
Ctrl-C (SIGINT) or kill -TERM <mcpls-pid> to the mcpls process.
- Observe the LSP server process is still running (orphaned), reparented to init/launchd, with no equivalent SIGTERM/SIGINT handler in
run_stdio to trigger cleanup.
Expected Behavior
run_stdio installs the same SIGINT/SIGTERM handling as run_http and, on receipt, drives an orderly shutdown: send LSP shutdown/exit to each running LspServer (via the currently-unused LspServer::shutdown()) before process exit, falling back to kill_on_drop only if a server doesn't respond in time.
Actual Behavior
No signal handler is registered for the stdio transport; abnormal termination (signal or panic) orphans every spawned LSP child process. On the normal exit path, LSP servers are killed rather than gracefully shut down, since LspServer::shutdown() is dead code in production.
Environment
- Version: 0.3.7 (current main, commit 2c8ad7f)
- Features: default (stdio transport)
Logs / Evidence
- crates/mcpls-core/src/transport.rs:84-102 (
run_stdio, no signal handling)
- crates/mcpls-core/src/transport.rs:120-196 (
run_http, has SIGTERM/SIGINT + graceful shutdown)
- crates/mcpls-core/src/lsp/lifecycle.rs:232-238 (
kill_on_drop(true))
- crates/mcpls-core/src/lsp/lifecycle.rs:445-459 (
LspServer::shutdown(), unreachable from production code — only referenced in bridge/state.rs test modules)
- Cargo.toml:60 (
panic = "abort" in [profile.release])
Description
run_http(crates/mcpls-core/src/transport.rs:120-196) installs SIGTERM/SIGINT handlers and drivesaxum::serve(...).with_graceful_shutdown(...), explicitly callingcancel.cancel()on signal receipt so the HTTP transport shuts down cleanly.run_stdio(crates/mcpls-core/src/transport.rs:84-102), which is the default transport used by every stdio-based MCP client (Claude Desktop, Claude Code, etc.), installs no signal handler at all — it just awaitsservice.waiting().Every spawned LSP child process (
LspServer::spawn, crates/mcpls-core/src/lsp/lifecycle.rs:232-238) relies ontokio::process::Command::kill_on_drop(true)to be reaped when mcpls exits.kill_on_droponly fires when theChild'sDropruns, i.e. during normal unwinding asmain()returns. On Unix, an uncaughtSIGINT/SIGTERMterminates the process immediately without running destructors (this is the default signal disposition, bypassed only if a handler is registered — whichrun_httpdoes andrun_stdiodoes not). The workspace also setspanic = "abort"(Cargo.toml:60), so any reachable panic has the same effect.Separately,
LspServer::shutdown()(crates/mcpls-core/src/lsp/lifecycle.rs:445-459), which sends the graceful LSPshutdownrequest +exitnotification, is never called fromserve_withor any production path — only from unit tests. So even on the clean-exit path (stdin EOF), spawned LSP servers are only ever killed, never given the LSP-level chance to shut down gracefully.Reproduction Steps
mcplsdirectly in a terminal (stdio mode, no--listen) with a configured LSP server (e.g. rust-analyzer) in a workspace.ps aux | grep rust-analyzer).Ctrl-C(SIGINT) orkill -TERM <mcpls-pid>to the mcpls process.run_stdioto trigger cleanup.Expected Behavior
run_stdioinstalls the same SIGINT/SIGTERM handling asrun_httpand, on receipt, drives an orderly shutdown: send LSPshutdown/exitto each runningLspServer(via the currently-unusedLspServer::shutdown()) before process exit, falling back tokill_on_droponly if a server doesn't respond in time.Actual Behavior
No signal handler is registered for the stdio transport; abnormal termination (signal or panic) orphans every spawned LSP child process. On the normal exit path, LSP servers are killed rather than gracefully shut down, since
LspServer::shutdown()is dead code in production.Environment
Logs / Evidence
run_stdio, no signal handling)run_http, has SIGTERM/SIGINT + graceful shutdown)kill_on_drop(true))LspServer::shutdown(), unreachable from production code — only referenced in bridge/state.rs test modules)panic = "abort"in[profile.release])