debugger: fix infinite loop when debugger pipe is closed

When a DAP client closes the pipe (or crashes), getPayload() returns an
empty functor without triggering the onError handler, so SessionActive
is never set to false and the session thread spins in a tight loop at
100% CPU. Handle the empty-payload case with the same cleanup performed
by the onError and DisconnectRequest handlers.

testProtocolWithPipesAbruptDisconnect drives the DAP handshake and then
closes the client side of the pipe without sending a DisconnectRequest.
The test deliberately avoids ReportExitCode so that no concurrent write
triggers the dap::Session error handler and masks the busy-loop
condition; if the SessionThread spins on EOF, the adapter destructor
blocks in SessionThread.join() and the test fails on a 10-second
timeout.

Fixes: #27743
This commit is contained in:
Christopher Wellons
2026-04-09 10:56:14 -04:00
parent 3b5342b305
commit 3490eff6f5
2 changed files with 124 additions and 1 deletions
+8
View File
@@ -312,6 +312,14 @@ cmDebuggerAdapter::cmDebuggerAdapter(
while (SessionActive.load()) {
if (auto payload = Session->getPayload()) {
payload();
} else {
// Connection closed or unrecoverable error.
BreakpointManager->ClearAll();
ExceptionManager->ClearAll();
ClearStepRequests();
ContinueSem->Notify();
DisconnectEvent->Fire();
SessionActive.store(false);
}
}
});
+116 -1
View File
@@ -7,6 +7,7 @@
#include <memory>
#include <stdexcept>
#include <string>
#include <thread>
#include <cm3p/cppdap/future.h>
#include <cm3p/cppdap/io.h>
@@ -173,7 +174,121 @@ bool testProtocolWithPipes()
return true;
}
bool testProtocolWithPipesAbruptDisconnect()
{
std::promise<void> debuggerConnectionCreatedPromise;
std::future<void> debuggerConnectionCreatedFuture =
debuggerConnectionCreatedPromise.get_future();
std::future<void> startedListeningFuture;
std::promise<void> adapterFinishedPromise;
std::future<void> adapterFinishedFuture =
adapterFinishedPromise.get_future();
std::promise<void> pipeClosedPromise;
std::future<void> pipeClosedFuture = pipeClosedPromise.get_future();
std::promise<bool> initializedEventReceivedPromise;
std::future<bool> initializedEventReceivedFuture =
initializedEventReceivedPromise.get_future();
auto futureTimeout = std::chrono::seconds(60);
auto disconnectTimeout = std::chrono::seconds(10);
#ifdef _WIN32
std::string namedPipe = R"(\\.\pipe\LOCAL\CMakeDebuggerPipe3_)" +
cmCryptoHash(cmCryptoHash::AlgoSHA256)
.HashString(cmsys::SystemTools::GetCurrentWorkingDirectory());
#else
std::string namedPipe = "CMakeDebuggerPipe3";
#endif
std::unique_ptr<dap::Session> client = dap::Session::create();
client->registerHandler([&](dap::InitializedEvent /*unused*/) {
initializedEventReceivedPromise.set_value(true);
});
// Raw thread (not ScopedThread): we need to be able to detach on
// failure so the test process can exit even if the bug is present.
//
// Note: we deliberately do NOT call ReportExitCode() here. With the
// bug present, an attempted write to a closed pipe would trigger the
// dap::Session error handler, which masks the busy-loop condition we
// are trying to test for. Instead we let the adapter destructor join
// the SessionThread directly: if SessionThread is spinning on EOF the
// join will hang, the adapterFinishedFuture wait below will time out,
// and the test will fail.
std::thread debuggerThread([&]() {
try {
auto connection =
std::make_shared<cmDebugger::cmDebuggerPipeConnection>(namedPipe);
startedListeningFuture = connection->StartedListening.get_future();
debuggerConnectionCreatedPromise.set_value();
std::shared_ptr<cmDebugger::cmDebuggerAdapter> debuggerAdapter =
std::make_shared<cmDebugger::cmDebuggerAdapter>(
connection, dap::file(stdout, false));
// Hold the adapter until the test signals that it has closed the
// client side of the pipe.
pipeClosedFuture.wait();
// Adapter destructed here; joins SessionThread.
} catch (std::runtime_error const&) {
// Swallowed: connection failures shouldn't hang the test.
}
adapterFinishedPromise.set_value();
});
ASSERT_TRUE(debuggerConnectionCreatedFuture.wait_for(futureTimeout) ==
std::future_status::ready);
ASSERT_TRUE(startedListeningFuture.wait_for(futureTimeout) ==
std::future_status::ready);
auto client2Debugger =
std::make_shared<cmDebugger::cmDebuggerPipeClient>(namedPipe);
client2Debugger->WaitForConnection();
client->bind(client2Debugger, client2Debugger);
// Drive the full handshake so that the debugger SessionThread is up
// and blocked reading the pipe.
dap::CMakeInitializeRequest initializeRequest;
auto initializeResponse = client->send(initializeRequest).get();
ASSERT_TRUE(!initializeResponse.error);
dap::LaunchRequest launchRequest;
auto launchResponse = client->send(launchRequest).get();
ASSERT_TRUE(!launchResponse.error);
dap::ConfigurationDoneRequest configurationDoneRequest;
auto configurationDoneResponse =
client->send(configurationDoneRequest).get();
ASSERT_TRUE(!configurationDoneResponse.error);
ASSERT_TRUE(initializedEventReceivedFuture.wait_for(futureTimeout) ==
std::future_status::ready);
// Abruptly close the client side without sending DisconnectRequest.
// Regression check for the busy-loop bug: the debugger adapter must
// detect EOF on the pipe and shut down on its own.
client2Debugger->close();
pipeClosedPromise.set_value();
bool finishedInTime = adapterFinishedFuture.wait_for(disconnectTimeout) ==
std::future_status::ready;
if (!finishedInTime) {
// Bug reproduced: the SessionThread is spinning on EOF and the
// adapter destructor is blocked in SessionThread.join(). Detach so
// the test process can exit instead of hanging in std::thread's
// destructor.
debuggerThread.detach();
ASSERT_TRUE(finishedInTime);
}
debuggerThread.join();
return true;
}
int testDebuggerAdapterPipe(int, char*[])
{
return runTests({ testProtocolWithPipes });
return runTests(
{ testProtocolWithPipes, testProtocolWithPipesAbruptDisconnect });
}