Fix use-after-free in IOTransport::OnRead on client disconnect - #198548
Conversation
|
@llvm/pr-subscribers-lldb Author: youngd007 ChangesWhen an MCP client disconnects (EOF),
Then when the scope is left, the destructor is called for the new read_handle local variable and it is cleaned up. New unit tests added that fail without this change. With the change, the custom 'ai' script (allows end user locally to communicate lldb context to agent backend via a spun up MCP server: "protocol-server start MCP listen://localhost:{port}") now successfully concludes without this crash Assisted with: claude Full diff: https://github.com/llvm/llvm-project/pull/198548.diff 2 Files Affected:
diff --git a/lldb/include/lldb/Host/JSONTransport.h b/lldb/include/lldb/Host/JSONTransport.h
index 6b114ee497a8b..c3a90cb0c3364 100644
--- a/lldb/include/lldb/Host/JSONTransport.h
+++ b/lldb/include/lldb/Host/JSONTransport.h
@@ -259,9 +259,11 @@ template <typename Proto> class IOTransport : public JSONTransport<Proto> {
if (!m_buffer.empty())
handler.OnError(llvm::make_error<TransportUnhandledContentsError>(
std::string(m_buffer.str())));
+ // Move the read handle to a local before notifying the handler. The
+ // handler may destroy this transport (e.g. by erasing it from a
+ // connection map), so accessing members after OnClosed() is unsafe.
+ auto read_handle = std::move(m_read_handle);
handler.OnClosed();
- // On EOF, remove the read handle from the MainLoop.
- m_read_handle.reset();
}
}
diff --git a/lldb/unittests/Host/JSONTransportTest.cpp b/lldb/unittests/Host/JSONTransportTest.cpp
index 2c26f94213773..3e977a70f5d4e 100644
--- a/lldb/unittests/Host/JSONTransportTest.cpp
+++ b/lldb/unittests/Host/JSONTransportTest.cpp
@@ -479,6 +479,15 @@ TEST_F(HTTPDelimitedJSONTransportTest, ReadWithEOF) {
ASSERT_THAT_ERROR(Run(), Succeeded());
}
+TEST_F(HTTPDelimitedJSONTransportTest, ReadWithEOFDestroyTransportOnClose) {
+ input.CloseWriteFileDescriptor();
+ EXPECT_CALL(message_handler, OnClosed()).WillOnce([this]() {
+ transport.reset();
+ loop.RequestTermination();
+ });
+ ASSERT_THAT_ERROR(loop.Run().takeError(), Succeeded());
+}
+
TEST_F(HTTPDelimitedJSONTransportTest, ReaderWithUnhandledData) {
std::string json = R"json({"str": "foo"})json";
std::string message =
@@ -588,6 +597,15 @@ TEST_F(JSONRPCTransportTest, ReadWithEOF) {
ASSERT_THAT_ERROR(Run(), Succeeded());
}
+TEST_F(JSONRPCTransportTest, ReadWithEOFDestroyTransportOnClose) {
+ input.CloseWriteFileDescriptor();
+ EXPECT_CALL(message_handler, OnClosed()).WillOnce([this]() {
+ transport.reset();
+ loop.RequestTermination();
+ });
+ ASSERT_THAT_ERROR(loop.Run().takeError(), Succeeded());
+}
+
TEST_F(JSONRPCTransportTest, ReaderWithUnhandledData) {
std::string message = R"json({"req": "foo")json";
// Write an incomplete message and close the handle.
|
* [lldb] Refactor JSONTransport own MainLoop read handle. (llvm#179564) When working with a MainLoop, if the file reaches the EOF it will immediately fire the read handle callback. We cannot readily determine if the file is at EOF or if the file is pointing to a socket/pipe that has consumed all the current data in the buffer but the remote end has not yet hung up. This is causing JSONTransport to continuously fire the OnRead callback trigging repeated calls to the `MessageHandler::OnClose`. Since MainLoop does not perform the actual read, we need to adjust the behavior of JSONTransport to fully own the read handle. This change moves the ownership of the `MainLoop::ReadHandleUP` and additionally own a reference to the `MainLoop` itself to ensure the loop outlives the JSONTransport object. This allows us to remove the handle immediately when we detect an EOF / hang up has occurred. (cherry picked from commit 69878f9) * Fix use-after-free in IOTransport::OnRead on client disconnect (llvm#198548) When an MCP client disconnects (EOF), `IOTransport::OnRead` called `handler.OnClosed()` before resetting `m_read_handle`. The MCP server's `OnClosed` handler erases the client from `m_instances`, destroying both the transport (`this`) and the binder (`handler`). The subsequent `m_read_handle.reset()` then accessed the destroyed transport's member, causing a use-after-free (SIGSEGV). * thread #1, stop reason = signal SIGSEGV: address not mapped to object (fault address=0x28) * frame #0: 0x00007ff5d4d5afda liblldb.so.23.2`lldb_private::transport::IOTransport<lldb_protocol::mcp::ProtocolDescriptor>::OnRead(lldb_private::MainLoopBase&, lldb_private::transport::JSONTransport<lldb_protocol::mcp::ProtocolDescriptor>::MessageHandler&) + 1274 frame #1: 0x00007ff5d1140ad8 liblldb.so.23.0`lldb_private::MainLoopPosix::Run() + 408 frame #2: 0x00007ff5d1760c1c liblldb.so.23.0`std::thread::_State_impl<std::thre Fix by resetting the read handle before calling `OnClosed()`, so no transport members are accessed after the handler potentially destroys the transport. Then when the scope is left, the destructor is called for the new read_handle local variable and it is cleaned up. New unit tests added that fail without this change. With the change, the custom 'ai' script (allows end user locally to communicate lldb context to agent backend via a spun up MCP server: "protocol-server start MCP listen://localhost:{port}") now successfully concludes without this crash Assisted with: claude (cherry picked from commit 1e1f3dd) * [lldb] Add unit tests for the MCP server (llvm#202752) Add unit-test coverage for the MCP protocol types and server under source/Protocol/MCP and the MCP plugin under source/Plugins/Protocol/MCP. The Server handlers run over the in-memory TestTransport, which gains SimulateError/SimulateClosed/SetRegisterMessageHandlerShouldFail helpers to drive the handler lifecycle without a real socket. Code that touches the filesystem or otherwise requires mucking with the test environment are deliberately left uncovered until those layers can be mocked. Assisted-by: Claude (cherry picked from commit a85441c) * [lldb] Add an MCP client and asynchronous request binding (llvm#208371) This PR contains the groundwork to convert lldb-mcp from a naive forwarder into a multiplexer. This requires acting both an MCP server and MCP client. This PR adds a new MCP Client abstraction, a thin wrapper over MCPTransport and MCPBinder exposing the protocol's requests as typed asynchronous calls. BindAsync hands an incoming-request handler a Reply it may invoke later. This PR also makes fromJSON symmetric with toJSON for ServerCapabilities. The former only restored supportsToolsList and silently dropped the resources, completions and logging capabilities, so a client parsing an initialize result never saw them. Assisted-by: Claude (cherry picked from commit 15f7d14) * [lldb-mcp] Replace byte forwarding with a protocol-aware multiplexer (llvm#208506) To serve several LLDB instances behind one endpoint it has to act as a multiplexer. This PR adds a Multiplexer that presents a unified MCP server to the client. It answers initialize and tools/list locally, and forwards tools/call and the resource requests to the different instances through an mcp::Client (added in llvm#208371), relaying the answer back. For now, this still drives a single backend. Discovering and routing across several instances is coming next. Assisted-by: Claude (cherry picked from commit 5ddad72) * [lldb-mcp] Multiplex across all discovered LLDB instances (llvm#208827) Connect to every LLDB MCP server advertised under ~/.lldb rather than a single one, and present them to the client as one server. A stale registry entry from a crashed instance simply fails to connect and is skipped. Each instance is identified by the pid of its lldb process, now recorded in the ServerInfo registry file. Tools and resources are addressed with instance-qualified URIs, e.g. lldb-mcp://instance/{pid}/debugger/{id} and lldb://instance/{pid}/debugger/{id}/target/{idx}. Listing requests (sessions_list, resources/list) fan out to every backend and aggregate; targeted requests (command, resources/read) are routed by the pid parsed from the URI. Backends only know their local lldb-mcp://debugger/{id} form, so URIs are rewritten in both directions. Add Binder::FailPendingRequests (and Client::CancelPendingRequests) so that when the client disconnects with a request still in flight to a backend, the abandoned reply is satisfied with an error instead of being destroyed unanswered, which would trip the binder's "must reply" assert. The multiplexer cancels its backends on shutdown before unwinding. Assisted-by: Claude (cherry picked from commit d6d0ccc) * [lldb] Add MCP tools to create and destroy debugger instances (llvm#209288) Add debugger_create and debugger_delete tools to the MCP server so a client can manage debugger instances, not just command the ones that already exist. debugger_create detaches the new debugger's stdio from the host process (redirecting input/output/error to the null device) so its prompt and asynchronous output cannot corrupt an MCP stream that shares the host's stdout. Command results flow through CommandReturnObject and are unaffected. Factor the tool and resource registration out of ProtocolServerMCP::Extend into a shared PopulateServer() so an embedded in-process server (e.g. in lldb-mcp) can install the same set. Assisted-by: Claude rdar://181722721 (cherry picked from commit a6d35f4) * [lldb][bazel] Add the lldb-mcp binary to the Bazel overlay (llvm#209498) llvm#143628 added the lldb-mcp tool (an MCP server multiplexer for LLDB), built by lldb/tools/lldb-mcp/CMakeLists.txt. The Bazel overlay already models the ProtocolMCP library and the MCP protocol-server plugin, but not the lldb-mcp executable itself, so it is missing from the Bazel build. Add an lldb-mcp cc_binary modeled on the existing lldb-dap target: glob tools/lldb-mcp/**, expand the macOS Info.plist via expand_template, and depend on liblldb.wrapper, Host, Utility, ProtocolMCP, and the LLVM Option/Support libraries. lldb-mcp has no Options.td, so unlike lldb-dap no gentbl_cc_library is needed. This properly translates into BUCk internally at Meta and then builds with buck2, but I cannot validate the bazel build of this new target directly. bazel rule generation assisted with: claude (cherry picked from commit 543e714) * [lldb] Add SBProtocolServer to start protocol servers programmatically (llvm#209923) Starting a protocol server (such as MCP, and in the future potentially DAP) is only possible from the command line today, via `protocol-server start`. Add an SB API so an embedder can start one in its own process and learn where to connect. SBProtocolServer wraps ProtocolServer::GetOrCreate/Start/Stop and exposes the listening connection URI. This lets a tool host the engine's protocol server in-process and talk to it as a normal client, reusing the server's tools rather than reimplementing them. Assisted-by: Claude (cherry picked from commit 9b096bb) * [lldb-mcp] Host managed debug sessions in-process (llvm#210450) Let a client create and own debug sessions with session_create and session_close. Rather than spawn a separate lldb per session, lldb-mcp hosts them in its own process, communicating over a loopback socket to keep things uniform with external lldb instances. The benefits of this approach are: - There is no child-process machinery, so nothing needs to be spawned and cleaned up. - It works without the need for an external lldb binary. - It avoids the deadlock by reading stdin through a raw fd instead of the FILE* stdio path that previously hung the Debugger constructor contending on the REPL's stdin lock. - The architecture stays uniform between in-process and external sessions. The trade-off is no isolation, so an LLDB crash takes down lldb-mcp along with its proxied connections. Assisted-by: Claude (cherry picked from commit 9208fc3) --------- Co-authored-by: John Harrison <harjohn@google.com> Co-authored-by: youngd007 <davidayoung@meta.com>
When an MCP client disconnects (EOF),
IOTransport::OnReadcalledhandler.OnClosed()before resettingm_read_handle. The MCP server'sOnClosedhandler erases the client fromm_instances, destroying boththe transport (
this) and the binder (handler). The subsequentm_read_handle.reset()then accessed the destroyed transport's member,causing a use-after-free (SIGSEGV).
thread Fixing Rust build #1, stop reason = signal SIGSEGV: address not mapped to object (fault address=0x28)
lldb_private::transport::IOTransport<lldb_protocol::mcp::ProtocolDescriptor>::OnRead(lldb_private::MainLoopBase&, lldb_private::transport::JSONTransport<lldb_protocol::mcp::ProtocolDescriptor>::MessageHandler&) + 1274 frame #1: 0x00007ff5d1140ad8 liblldb.so.23.0lldb_private::MainLoopPosix::Run() + 408frame Fix a typo #2: 0x00007ff5d1760c1c liblldb.so.23.0`std::thread::_State_impl<std::thre
Fix by resetting the read handle before calling
OnClosed(), so notransport members are accessed after the handler potentially destroys
the transport.
Then when the scope is left, the destructor is called for the new read_handle local variable and it is cleaned up.
New unit tests added that fail without this change. With the change, the custom 'ai' script (allows end user locally to communicate lldb context to agent backend via a spun up MCP server: "protocol-server start MCP listen://localhost:{port}") now successfully concludes without this crash
Assisted with: claude