yams/tests/unit/mcp/mcp_timeout_catch2_test.cpp
2026-08-04 21:31:39 -04:00

262 lines
10 KiB
C++

// MCP Timeout Tests
//
// Gap 1: MCPServer constructor set daemon client requestTimeout=60s and
// bodyTimeout=120s — far too permissive for interactive MCP. A hung
// daemon meant a 60s hang. Fixed: 15s requestTimeout, 60s bodyTimeout.
//
// Gap 2: callTool() / processMessage() blocked callers on future.get() with
// no internal deadline. Fixed: 30s safety deadline on all sync bridges.
//
// Gap 3: Async tool calls (detached coroutines) had no server-side deadline.
// Fixed: 30s deadline timer races against the tool call.
//
// Fast-fail: With properly configured short timeouts, all tools return
// promptly when the daemon is unreachable.
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <future>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/detached.hpp>
#include <nlohmann/json.hpp>
#include <yams/daemon/client/global_io_context.h>
#include <yams/mcp/mcp_server.h>
#include <yams/mcp/tool_registry.h>
#include "../../common/test_helpers_catch2.h"
using namespace std::chrono_literals;
using json = nlohmann::json;
using Catch::Matchers::ContainsSubstring;
namespace {
class NullTransport : public yams::mcp::ITransport {
public:
void send(const json&) override {}
yams::mcp::MessageResult receive() override {
return yams::Error{yams::ErrorCode::NotImplemented, "Null transport"};
}
bool isConnected() const override { return false; }
void close() override {}
yams::mcp::TransportState getState() const override {
return yams::mcp::TransportState::Disconnected;
}
};
} // namespace
// ============================================================================
// GAP 1 (FAILING → NOW PASSING with fix):
// MCP server default requestTimeout was 60s (excessive).
// After fix: 15s for requestTimeout, 60s for bodyTimeout.
// ============================================================================
TEST_CASE("MCP server default daemon timeouts are now reasonable",
"[mcp][timeout][gap][default_config]") {
auto transport = std::make_unique<NullTransport>();
auto server = std::make_shared<yams::mcp::MCPServer>(std::move(transport));
std::optional<yams::daemon::ClientConfig> captured;
server->testSetEnsureDaemonClientHook(
[&captured](const yams::daemon::ClientConfig& cfg) -> yams::Result<void> {
captured = cfg;
return yams::Error{yams::ErrorCode::NetworkError, "captured for verification"};
});
server->callToolPublic("search", json{{"query", "test"}});
REQUIRE(captured.has_value());
INFO("requestTimeout: " << captured->requestTimeout.count() << "ms");
INFO("headerTimeout: " << captured->headerTimeout.count() << "ms");
INFO("bodyTimeout: " << captured->bodyTimeout.count() << "ms");
INFO("connectTimeout: " << captured->connectTimeout.count() << "ms");
// After fix: 15s requestTimeout (was 60s), 60s bodyTimeout (was 120s)
CHECK(captured->requestTimeout.count() == 15000);
CHECK(captured->bodyTimeout.count() == 60000);
CHECK(captured->headerTimeout.count() == 10000);
}
// ============================================================================
// GAP 2 (FAILING → NOW PASSING with fix):
// callTool() now has a 30s deadline on future.wait_for().
// Verify callTool returns promptly when daemon is unreachable (short
// timeouts via hook).
// ============================================================================
TEST_CASE("callTool returns error promptly when daemon unreachable",
"[mcp][timeout][gap][fastfail]") {
yams::test::ScopedEnvVar discovery{"YAMS_ENABLE_LOCAL_MCP_DISCOVERY", std::string{"0"}};
yams::test::ScopedEnvVar autoStart{"YAMS_CLI_DISABLE_DAEMON_AUTOSTART", std::string{"1"}};
auto transport = std::make_unique<NullTransport>();
auto server = std::make_shared<yams::mcp::MCPServer>(std::move(transport));
auto socketPath =
std::filesystem::temp_directory_path() /
("yams-mcp-fastfail-" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".sock");
yams::test::ScopedEnvVar socketEnvironment{"YAMS_DAEMON_SOCKET_PATH", socketPath.string()};
yams::daemon::ClientConfig cfg;
cfg.autoStart = false;
cfg.maxRetries = 0;
cfg.connectTimeout = std::chrono::milliseconds{50};
cfg.requestTimeout = std::chrono::milliseconds{50};
cfg.socketPath = socketPath;
std::error_code ec;
std::filesystem::remove(cfg.socketPath, ec);
server->testConfigureDaemonClient(cfg);
server->testSetEnsureDaemonClientHook(
[](const yams::daemon::ClientConfig&) -> yams::Result<void> {
return yams::Error{yams::ErrorCode::NetworkError, "No daemon"};
});
for (const auto& toolName :
{"search", "grep", "list", "get", "status", "stats", "doctor", "cat", "graph", "add"}) {
auto start = std::chrono::steady_clock::now();
json args = json::object();
if (std::string(toolName) == "search")
args["query"] = "test";
else if (std::string(toolName) == "grep")
args["pattern"] = "test";
else if (std::string(toolName) == "get" || std::string(toolName) == "cat")
args["hash"] = "abc123";
else if (std::string(toolName) == "add") {
args["content"] = "x";
args["name"] = "x.txt";
}
auto result = server->callToolPublic(toolName, args);
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start);
REQUIRE(result.is_object());
INFO("Tool '" << toolName << "' elapsed: " << elapsed.count() << "ms");
CHECK(elapsed < 500ms);
}
server->testShutdown();
std::filesystem::remove(socketPath, ec);
}
// ============================================================================
// GAP 3 (FAILING → NOW PASSING with fix):
// processMessage() now has a 30s deadline on future.wait_for().
// ============================================================================
TEST_CASE("processMessage returns promptly for tools/call when daemon down",
"[mcp][timeout][gap][processmessage]") {
yams::test::ScopedEnvVar discovery{"YAMS_ENABLE_LOCAL_MCP_DISCOVERY", std::string{"0"}};
yams::test::ScopedEnvVar autoStart{"YAMS_CLI_DISABLE_DAEMON_AUTOSTART", std::string{"1"}};
auto transport = std::make_unique<NullTransport>();
auto server = std::make_shared<yams::mcp::MCPServer>(std::move(transport));
auto socketPath =
std::filesystem::temp_directory_path() /
("yams-mcp-procm-" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".sock");
yams::test::ScopedEnvVar socketEnvironment{"YAMS_DAEMON_SOCKET_PATH", socketPath.string()};
yams::daemon::ClientConfig cfg;
cfg.autoStart = false;
cfg.maxRetries = 0;
cfg.connectTimeout = std::chrono::milliseconds{50};
cfg.requestTimeout = std::chrono::milliseconds{50};
cfg.socketPath = socketPath;
std::error_code ec;
std::filesystem::remove(cfg.socketPath, ec);
server->testConfigureDaemonClient(cfg);
server->testSetEnsureDaemonClientHook(
[](const yams::daemon::ClientConfig&) -> yams::Result<void> {
return yams::Error{yams::ErrorCode::NetworkError, "No daemon"};
});
json request = {{"jsonrpc", "2.0"},
{"id", 1},
{"method", "tools/call"},
{"params", {{"name", "search"}, {"arguments", {{"query", "test"}}}}}};
auto start = std::chrono::steady_clock::now();
auto response = server->processMessage(request);
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start);
INFO("processMessage elapsed: " << elapsed.count() << "ms");
REQUIRE(response.has_value());
CHECK(elapsed < 1000ms);
server->testShutdown();
std::filesystem::remove(socketPath, ec);
}
// ============================================================================
// Sequential calls don't degrade or leak
// ============================================================================
TEST_CASE("consecutive callTool calls do not progressively slow down",
"[mcp][timeout][sequential]") {
yams::test::ScopedEnvVar discovery{"YAMS_ENABLE_LOCAL_MCP_DISCOVERY", std::string{"0"}};
yams::test::ScopedEnvVar autoStart{"YAMS_CLI_DISABLE_DAEMON_AUTOSTART", std::string{"1"}};
auto transport = std::make_unique<NullTransport>();
auto server = std::make_shared<yams::mcp::MCPServer>(std::move(transport));
auto socketPath =
std::filesystem::temp_directory_path() /
("yams-mcp-sequential-" +
std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + ".sock");
yams::test::ScopedEnvVar socketEnvironment{"YAMS_DAEMON_SOCKET_PATH", socketPath.string()};
yams::daemon::ClientConfig cfg;
cfg.autoStart = false;
cfg.maxRetries = 0;
cfg.connectTimeout = std::chrono::milliseconds{50};
cfg.requestTimeout = std::chrono::milliseconds{50};
cfg.socketPath = socketPath;
std::error_code ec;
std::filesystem::remove(cfg.socketPath, ec);
server->testConfigureDaemonClient(cfg);
server->testSetEnsureDaemonClientHook(
[](const yams::daemon::ClientConfig&) -> yams::Result<void> {
return yams::Error{yams::ErrorCode::NetworkError, "No daemon"};
});
constexpr int kCalls = 10;
std::vector<std::chrono::milliseconds> elapsedTimes;
for (int i = 0; i < kCalls; ++i) {
auto start = std::chrono::steady_clock::now();
auto result = server->callToolPublic("search", json{{"query", "test"}});
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start);
elapsedTimes.push_back(elapsed);
REQUIRE(result.is_object());
INFO("call " << i << " elapsed: " << elapsed.count() << "ms");
CHECK(elapsed < 1000ms);
}
for (size_t i = 0; i < elapsedTimes.size(); ++i) {
INFO("call " << i << " latency: " << elapsedTimes[i].count() << "ms");
CHECK(elapsedTimes[i] < 1000ms);
}
server->testShutdown();
std::filesystem::remove(socketPath, ec);
}