Skip to content

Commit 4cf8907

Browse files
fix: address all 15 code review issues (critical/important/suggestions)
Critical: fix double-shutdown in capture.cpp via RAII scoping, fix CoreError vtable comment. Important: add close_capture/session_status MCP tools (54 total), replace bare catch(...) with typed catches in snapshot.cpp, prefer NewCapture message path over filesystem scan, add path traversal validation for exports, pre-lowercase filter in events.cpp. Suggestions: add min/max/minLength/maxLength JSON Schema validation, return const char* from string converters, validate client protocolVersion, consistent ExportResult serialization, use int64_t in assertCount, add MCP shutdown method, remove stale comment. All 103 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0c022ea commit 4cf8907

19 files changed

Lines changed: 317 additions & 101 deletions

src/core/assertions.cpp

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -158,25 +158,25 @@ ImageCompareResult assertImage(const std::string& expectedPath,
158158
// --- assert_count ---
159159
AssertResult assertCount(const Session& session,
160160
const std::string& what,
161-
int expected,
161+
int64_t expected,
162162
const std::string& op) {
163163
auto* ctrl = session.controller();
164-
int actual = 0;
164+
int64_t actual = 0;
165165
if (what == "events") {
166166
auto st = session.status();
167-
actual = static_cast<int>(st.totalEvents);
167+
actual = static_cast<int64_t>(st.totalEvents);
168168
} else if (what == "draws") {
169169
auto draws = listDraws(session, "", UINT32_MAX);
170-
actual = static_cast<int>(draws.size());
170+
actual = static_cast<int64_t>(draws.size());
171171
} else if (what == "textures") {
172172
auto textures = ctrl->GetTextures();
173-
actual = static_cast<int>(textures.size());
173+
actual = static_cast<int64_t>(textures.size());
174174
} else if (what == "buffers") {
175175
auto buffers = ctrl->GetBuffers();
176-
actual = static_cast<int>(buffers.size());
176+
actual = static_cast<int64_t>(buffers.size());
177177
} else if (what == "passes") {
178178
auto passes = listPasses(session);
179-
actual = static_cast<int>(passes.size());
179+
actual = static_cast<int64_t>(passes.size());
180180
} else {
181181
throw CoreError(CoreError::Code::InternalError,
182182
"Unknown count target: " + what);

src/core/assertions.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ ImageCompareResult assertImage(const std::string& expectedPath,
2828

2929
AssertResult assertCount(const Session& session,
3030
const std::string& what,
31-
int expected,
31+
int64_t expected,
3232
const std::string& op = "eq");
3333

3434
struct CleanAssertResult {

src/core/capture.cpp

Lines changed: 62 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -248,75 +248,84 @@ CaptureResult captureFrame(Session& session, const CaptureRequest& req) {
248248
throw CoreError(CoreError::Code::InternalError,
249249
"Failed to connect to target process");
250250

251-
// RAII guard for ctrl->Shutdown()
251+
// RAII guard for ctrl->Shutdown() — single cleanup path, no manual Shutdown()
252252
struct CtrlGuard {
253253
ITargetControl* c;
254254
~CtrlGuard() { if (c) c->Shutdown(); }
255-
} guard{ctrl};
256-
257-
uint32_t pid = ctrl->GetPID();
255+
};
258256

259-
// 7. Wait for delayFrames, then trigger capture.
257+
std::string foundCapture;
258+
uint32_t pid = 0;
260259
{
261-
uint32_t waitMs = req.delayFrames * 16; // ~16ms per frame at 60fps
262-
if (waitMs < 2000) waitMs = 2000; // minimum 2s for API init
263-
auto waitUntil = std::chrono::steady_clock::now() +
264-
std::chrono::milliseconds(waitMs);
265-
while (std::chrono::steady_clock::now() < waitUntil) {
266-
if (!ctrl->Connected())
267-
throw CoreError(CoreError::Code::InternalError,
268-
"Target process exited during wait");
269-
TargetControlMessage msg = ctrl->ReceiveMessage(nullptr);
270-
if (msg.type == TargetControlMessageType::Disconnected)
271-
throw CoreError(CoreError::Code::InternalError,
272-
"Target process disconnected during wait");
273-
std::this_thread::sleep_for(std::chrono::milliseconds(50));
260+
CtrlGuard guard{ctrl};
261+
262+
pid = ctrl->GetPID();
263+
264+
// 7. Wait for delayFrames, then trigger capture.
265+
{
266+
uint32_t waitMs = req.delayFrames * 16; // ~16ms per frame at 60fps
267+
if (waitMs < 2000) waitMs = 2000; // minimum 2s for API init
268+
auto waitUntil = std::chrono::steady_clock::now() +
269+
std::chrono::milliseconds(waitMs);
270+
while (std::chrono::steady_clock::now() < waitUntil) {
271+
if (!ctrl->Connected())
272+
throw CoreError(CoreError::Code::InternalError,
273+
"Target process exited during wait");
274+
TargetControlMessage msg = ctrl->ReceiveMessage(nullptr);
275+
if (msg.type == TargetControlMessageType::Disconnected)
276+
throw CoreError(CoreError::Code::InternalError,
277+
"Target process disconnected during wait");
278+
std::this_thread::sleep_for(std::chrono::milliseconds(50));
279+
}
274280
}
275-
}
276281

277-
ctrl->TriggerCapture(1);
282+
ctrl->TriggerCapture(1);
278283

279-
// 8. Poll for NewCapture message
280-
bool captured = false;
281-
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(60);
284+
// 8. Poll for NewCapture message
285+
bool captured = false;
286+
std::string captureMsgPath; // path from NewCapture message (preferred)
287+
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(60);
282288

283-
while (std::chrono::steady_clock::now() < deadline) {
284-
if (!ctrl->Connected())
285-
break;
289+
while (std::chrono::steady_clock::now() < deadline) {
290+
if (!ctrl->Connected())
291+
break;
286292

287-
TargetControlMessage msg = ctrl->ReceiveMessage(nullptr);
293+
TargetControlMessage msg = ctrl->ReceiveMessage(nullptr);
288294

289-
if (msg.type == TargetControlMessageType::NewCapture) {
290-
captured = true;
291-
break;
292-
}
293-
if (msg.type == TargetControlMessageType::Disconnected)
294-
break;
295+
if (msg.type == TargetControlMessageType::NewCapture) {
296+
captured = true;
297+
captureMsgPath = std::string(msg.newCapture.path.c_str());
298+
break;
299+
}
300+
if (msg.type == TargetControlMessageType::Disconnected)
301+
break;
295302

296-
std::this_thread::sleep_for(std::chrono::milliseconds(100));
297-
}
303+
std::this_thread::sleep_for(std::chrono::milliseconds(100));
304+
}
298305

299-
// 9. Find the capture file on disk.
300-
// RenderDoc saves captures to its default temp directory (%TEMP%/RenderDoc/)
301-
// or the template path we provided.
302-
auto exeName = fs::path(req.exePath).stem().string();
303-
std::vector<fs::path> searchDirs = {
304-
fs::path(captureTemplate).parent_path(),
305-
fs::temp_directory_path() / "RenderDoc",
306-
};
307-
std::string foundCapture = findNewestCapture(exeName, searchDirs);
306+
// 9. Find the capture file on disk.
307+
// Prefer the path from the NewCapture message (avoids filesystem race).
308+
// Fall back to scanning directories if the message path is unavailable.
309+
if (!captureMsgPath.empty() && fs::exists(captureMsgPath)) {
310+
foundCapture = captureMsgPath;
311+
} else {
312+
auto exeName = fs::path(req.exePath).stem().string();
313+
std::vector<fs::path> searchDirs = {
314+
fs::path(captureTemplate).parent_path(),
315+
fs::temp_directory_path() / "RenderDoc",
316+
};
317+
foundCapture = findNewestCapture(exeName, searchDirs);
318+
}
308319

309-
if (foundCapture.empty())
310-
throw CoreError(CoreError::Code::InternalError,
311-
captured ? "Capture completed but file not found on disk"
312-
: "Capture timed out and no capture file found");
320+
if (foundCapture.empty())
321+
throw CoreError(CoreError::Code::InternalError,
322+
captured ? "Capture completed but file not found on disk"
323+
: "Capture timed out and no capture file found");
313324

314-
if (foundCapture != outputPath)
315-
fs::copy_file(foundCapture, outputPath, fs::copy_options::overwrite_existing);
325+
if (foundCapture != outputPath)
326+
fs::copy_file(foundCapture, outputPath, fs::copy_options::overwrite_existing);
316327

317-
// 10. Cleanup
318-
guard.c = nullptr;
319-
ctrl->Shutdown();
328+
} // 10. guard destructor calls ctrl->Shutdown()
320329

321330
// 11. Auto-open the capture
322331
session.open(outputPath);

src/core/errors.cpp

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
#include "core/errors.h"
22

3-
// CoreError is fully inline in the header.
4-
// This translation unit exists so the CMake target always has at least one .cpp
5-
// and to anchor the vtable in a single TU.
6-
namespace renderdoc::core {
7-
// intentionally empty - vtable anchored by out-of-line destructor if needed
8-
} // namespace renderdoc::core
3+
// CoreError destructor is inline (= default in class body).
4+
// On MSVC, vtable emission with inline destructors works correctly.
5+
// This TU exists to ensure the CMake target always has at least one .cpp file
6+
// and to provide a future anchor point if cross-platform needs arise.
7+
namespace renderdoc::core {} // namespace renderdoc::core

src/core/errors.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ class CoreError : public std::runtime_error {
3737
CoreError(Code code, const std::string& message)
3838
: std::runtime_error(message), m_code(code) {}
3939

40+
~CoreError() override = default;
41+
4042
Code code() const { return m_code; }
4143

4244
private:

src/core/events.cpp

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,8 @@ void collectEvents(const rdcarray<ActionDescription>& actions,
3636
out.push_back(std::move(info));
3737
} else {
3838
std::string lowerName = info.name;
39-
std::string lowerFilter = filter;
4039
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), ::tolower);
41-
std::transform(lowerFilter.begin(), lowerFilter.end(), lowerFilter.begin(), ::tolower);
42-
if (lowerName.find(lowerFilter) != std::string::npos)
40+
if (lowerName.find(filter) != std::string::npos)
4341
out.push_back(std::move(info));
4442
}
4543

@@ -63,10 +61,8 @@ void collectDrawCalls(const rdcarray<ActionDescription>& actions,
6361
bool matches = true;
6462
if (!filter.empty()) {
6563
std::string lowerName = info.name;
66-
std::string lowerFilter = filter;
6764
std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), ::tolower);
68-
std::transform(lowerFilter.begin(), lowerFilter.end(), lowerFilter.begin(), ::tolower);
69-
matches = (lowerName.find(lowerFilter) != std::string::npos);
65+
matches = (lowerName.find(filter) != std::string::npos);
7066
}
7167

7268
if (matches)
@@ -99,8 +95,12 @@ std::vector<EventInfo> listEvents(const Session& session, const std::string& fil
9995
auto* ctrl = session.controller(); // throws NoCaptureOpen if not open
10096
const auto& actions = ctrl->GetRootActions();
10197

98+
// Pre-lowercase the filter once, rather than per-event.
99+
std::string lowerFilter = filter;
100+
std::transform(lowerFilter.begin(), lowerFilter.end(), lowerFilter.begin(), ::tolower);
101+
102102
std::vector<EventInfo> result;
103-
collectEvents(actions, filter, result);
103+
collectEvents(actions, lowerFilter, result);
104104
return result;
105105
}
106106

@@ -110,8 +110,12 @@ std::vector<EventInfo> listDraws(const Session& session,
110110
auto* ctrl = session.controller(); // throws NoCaptureOpen if not open
111111
const auto& actions = ctrl->GetRootActions();
112112

113+
// Pre-lowercase the filter once, rather than per-draw.
114+
std::string lowerFilter = filter;
115+
std::transform(lowerFilter.begin(), lowerFilter.end(), lowerFilter.begin(), ::tolower);
116+
113117
std::vector<EventInfo> result;
114-
collectDrawCalls(actions, filter, limit, result);
118+
collectDrawCalls(actions, lowerFilter, limit, result);
115119
return result;
116120
}
117121

src/core/export.cpp

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,19 @@ namespace renderdoc::core {
1717

1818
namespace {
1919

20+
// Validate that the resolved output path stays within the intended output directory.
21+
// Prevents path traversal attacks (e.g., "../../etc/sensitive").
22+
void validateOutputDir(const std::string& outputDir) {
23+
auto canonical = fs::weakly_canonical(fs::path(outputDir));
24+
auto canonicalStr = canonical.string();
25+
// Reject if the path contains ".." components after canonicalization
26+
// (weakly_canonical resolves them, so just check the input).
27+
auto input = fs::path(outputDir).lexically_normal().string();
28+
if (input.find("..") != std::string::npos)
29+
throw CoreError(CoreError::Code::InvalidPath,
30+
"Output directory must not contain path traversal (..): " + outputDir);
31+
}
32+
2033
// Sanitize a string for use in a filename (replace :: with __ and : with _).
2134
std::string sanitizeForFilename(const std::string& s) {
2235
std::string out = s;
@@ -39,6 +52,7 @@ ExportResult exportRenderTarget(const Session& session,
3952
int rtIndex,
4053
const std::string& outputDir)
4154
{
55+
validateOutputDir(outputDir);
4256
auto* ctrl = session.controller();
4357

4458
// Walk the action tree to find the current event and retrieve its output RT.
@@ -119,6 +133,7 @@ ExportResult exportTexture(const Session& session,
119133
uint32_t mip,
120134
uint32_t layer)
121135
{
136+
validateOutputDir(outputDir);
122137
auto* ctrl = session.controller();
123138

124139
::ResourceId rdcId = fromResourceId(id);
@@ -157,6 +172,7 @@ ExportResult exportBuffer(const Session& session,
157172
uint64_t offset,
158173
uint64_t size)
159174
{
175+
validateOutputDir(outputDir);
160176
auto* ctrl = session.controller();
161177

162178
::ResourceId rdcId = fromResourceId(id);

src/core/snapshot.cpp

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ std::string stageToFileSuffix(ShaderStage stage) {
3939
SnapshotResult exportSnapshot(Session& session, uint32_t eventId,
4040
const std::string& outputDir,
4141
std::function<std::string(const PipelineState&)> pipelineSerializer) {
42+
// Validate output directory for path traversal
43+
auto normalizedDir = fs::path(outputDir).lexically_normal().string();
44+
if (normalizedDir.find("..") != std::string::npos)
45+
throw CoreError(CoreError::Code::InvalidPath,
46+
"Output directory must not contain path traversal (..): " + outputDir);
47+
4248
auto* ctrl = session.controller();
4349

4450
ctrl->SetFrameEvent(eventId, true);
@@ -83,8 +89,12 @@ SnapshotResult exportSnapshot(Session& session, uint32_t eventId,
8389
result.files.push_back(path);
8490
}
8591
}
86-
} catch (...) {
87-
// No shader bound at this stage — skip silently.
92+
} catch (const CoreError& e) {
93+
// No shader bound at this stage — skip silently for expected errors.
94+
if (e.code() != CoreError::Code::NoShaderBound)
95+
result.errors.push_back(std::string("Shader export (") + stageToFileSuffix(stage) + "): " + e.what());
96+
} catch (const std::exception& e) {
97+
result.errors.push_back(std::string("Shader export (") + stageToFileSuffix(stage) + "): " + e.what());
8898
}
8999
}
90100

@@ -96,17 +106,20 @@ SnapshotResult exportSnapshot(Session& session, uint32_t eventId,
96106
// Rename to color{i}.png for snapshot convention.
97107
std::string targetName = "color" + std::to_string(i) + ".png";
98108
std::string targetPath = (fs::path(outputDir) / targetName).string();
99-
// The export already wrote with its own naming; rename it.
100109
try {
101110
fs::rename(exp.outputPath, targetPath);
102111
result.files.push_back(targetPath);
103-
} catch (...) {
112+
} catch (const std::exception&) {
104113
// If rename fails, keep original path.
105114
result.files.push_back(exp.outputPath);
106115
}
107116
}
108-
} catch (...) {
109-
// No RT at this index — skip.
117+
} catch (const CoreError& e) {
118+
// No RT at this index — skip silently for expected errors.
119+
if (e.code() != CoreError::Code::ExportFailed && e.code() != CoreError::Code::InvalidEventId)
120+
result.errors.push_back(std::string("RT export (") + std::to_string(i) + "): " + e.what());
121+
} catch (const std::exception& e) {
122+
result.errors.push_back(std::string("RT export (") + std::to_string(i) + "): " + e.what());
110123
}
111124
}
112125

@@ -147,8 +160,10 @@ SnapshotResult exportSnapshot(Session& session, uint32_t eventId,
147160
result.files.push_back(exp.outputPath);
148161
}
149162
}
150-
} catch (...) {
151-
// Depth export failed — not critical.
163+
} catch (const CoreError&) {
164+
// Depth export failed — not critical for snapshot.
165+
} catch (const std::exception& e) {
166+
result.errors.push_back(std::string("Depth export: ") + e.what());
152167
}
153168
}
154169
}

src/mcp/mcp_server.cpp

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ json McpServer::handleMessage(const json& msg)
8787
m_initialized = true;
8888
return nullptr; // No response for notifications
8989
}
90+
else if(method == "shutdown")
91+
{
92+
shutdown();
93+
return makeResponse(id, json::object());
94+
}
9095
else if(method == "tools/list" || method == "tools/call")
9196
{
9297
if(!m_initialized && !isNotification)
@@ -136,8 +141,20 @@ json McpServer::handleInitialize(const json& msg)
136141
{
137142
json id = msg.value("id", json(nullptr));
138143

144+
// Validate client protocol version for compatibility
145+
static constexpr const char* kSupportedProtocolVersion = "2025-03-26";
146+
json params = msg.value("params", json::object());
147+
if (params.contains("protocolVersion")) {
148+
std::string clientVersion = params["protocolVersion"].get<std::string>();
149+
if (clientVersion != kSupportedProtocolVersion) {
150+
return makeError(id, -32602,
151+
"Unsupported protocol version: " + clientVersion +
152+
" (server supports " + kSupportedProtocolVersion + ")");
153+
}
154+
}
155+
139156
json result;
140-
result["protocolVersion"] = "2025-03-26";
157+
result["protocolVersion"] = kSupportedProtocolVersion;
141158
result["capabilities"]["tools"] = json::object();
142159
result["serverInfo"]["name"] = "renderdoc-mcp";
143160
result["serverInfo"]["version"] = "1.0.0";

0 commit comments

Comments
 (0)