Files
西风逍遥游andCursor Agent 2d7b9f9287 feat(devtools): script error tracing with dynamic slicer (#4)
* feat(devtools): add dynamic slicer for script error tracing

Record call/return/line and Def/Use data-flow in CallGraph, attach a
Squirrel native debug hook via DevTool, and emit a Weiser-style backward
slice report on failures when running with `eve run --debug`.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* fix(devtools): use one-in-one-out ring buffer for trace events

Replace half-window drops with a deque ring buffer: when full, each new
event evicts exactly one oldest event, with monotonic ids and index cleanup.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* feat(devtools): add render-pipeline error tracing

Introduce common RenderTrace hooks and a RenderFlow slicer so Graphics
passes/binds/draws are attributed on failure. Wire RenderSystem 2D/3D,
mark Exception sites, and merge render reports into DevTool --debug output.

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

* test(devtools): expand CallGraph and RenderFlow coverage

Add extensive unit tests for source locations, data-flow slicing,
ring-buffer eviction, nested passes/binds, Exception hooks, and RAII
scopes (54 cases / 202 assertions under ^devtools\.).

Co-authored-by: 西风逍遥游 <sunxfancy@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-09 01:00:43 +08:00

497 lines
17 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "devtools/CallGraph.hpp"
#include <algorithm>
#include <cstdio>
#include <set>
#include <string>
#include <unordered_set>
using namespace eve::dev;
static SourceLoc loc(const char* src, int line, const char* fn = "") {
SourceLoc l;
l.source = src;
l.line = line;
l.function = fn ? fn : "";
return l;
}
static bool sliceHasLoc(const SliceResult& s, const char* src, int line) {
for (const auto& l : s.locations) {
if (l.source == src && l.line == line) return true;
}
return false;
}
static bool sliceHasKind(const CallGraph& g, const SliceResult& s, TraceKind kind) {
for (uint32_t id : s.eventIds) {
const TraceEvent* e = g.event(id);
if (e && e->kind == kind) return true;
}
return false;
}
static bool dataFlowFromLine(const CallGraph& g, const SliceResult& s, const char* var, int line) {
for (const auto& e : s.dataFlow) {
if (e.var != var) continue;
const TraceEvent* from = g.event(e.fromEventId);
if (from && from->loc.line == line) return true;
}
return false;
}
// ---------------------------------------------------------------------------
// SourceLoc
// ---------------------------------------------------------------------------
TEST_CASE("devtools.sourceloc.emptyAndMatches") {
SourceLoc a;
CHECK(a.empty());
SourceLoc b = loc("a.nut", 10, "f");
CHECK(!b.empty());
CHECK(b.matches(loc("a.nut", 10, "f")));
CHECK(b.matches(loc("a.nut", 10))); // function optional
CHECK(b.matches(loc("", 10, "f"))); // source optional when empty
CHECK(!b.matches(loc("a.nut", 11, "f")));
CHECK(!b.matches(loc("b.nut", 10, "f")));
CHECK(b.toString().find("a.nut:10") != std::string::npos);
CHECK(b.toString().find("in f") != std::string::npos);
SourceLoc unk;
unk.line = 3;
CHECK(unk.toString().find("<unknown>:3") != std::string::npos);
}
// ---------------------------------------------------------------------------
// Recording basics
// ---------------------------------------------------------------------------
TEST_CASE("devtools.callgraph.stackAndEdges") {
CallGraph g;
g.onCall(loc("a.nut", 1, "main"), "main");
g.onLine(loc("a.nut", 2, "main"));
g.onCall(loc("a.nut", 3, "foo"), "foo");
g.onLine(loc("a.nut", 10, "foo"));
g.onReturn(loc("a.nut", 11, "foo"), "foo");
g.onLine(loc("a.nut", 4, "main"));
auto stack = g.currentStack();
REQUIRE(stack.size() == 1u);
CHECK_EQ(stack[0].loc.function, std::string("main"));
auto edges = g.callEdges();
REQUIRE(edges.size() == 1u);
CHECK_EQ(edges[0].first.function, std::string("main"));
CHECK_EQ(edges[0].second.function, std::string("foo"));
}
TEST_CASE("devtools.callgraph.enterHelperAndEventLookup") {
CallGraph g;
const uint32_t lineId = g.enter(loc("e.nut", 1, "main"), "main");
REQUIRE(g.event(lineId) != nullptr);
const bool isLine = g.event(lineId)->kind == TraceKind::Line;
CHECK(isLine);
CHECK(g.event(0) == nullptr);
CHECK(g.event(999999) == nullptr);
CHECK_EQ(g.eventCount(), 2u); // Call + Line
CHECK_EQ(g.currentStack().size(), 1u);
}
TEST_CASE("devtools.callgraph.emptyVarIgnored") {
CallGraph g;
g.onCall(loc("v.nut", 1, "main"), "main");
CHECK_EQ(g.onDef(loc("v.nut", 2), ""), 0u);
CHECK_EQ(g.onUse(loc("v.nut", 3), ""), 0u);
CHECK_EQ(g.eventCount(), 1u);
}
TEST_CASE("devtools.callgraph.clearResetsState") {
CallGraph g;
g.onCall(loc("c.nut", 1, "main"), "main");
g.onDef(loc("c.nut", 2), "x");
g.onUse(loc("c.nut", 3), "x");
CHECK(g.eventCount() > 0);
g.clear();
CHECK_EQ(g.eventCount(), 0u);
CHECK(g.currentStack().empty());
CHECK(g.callEdges().empty());
SliceResult empty = g.sliceBackward({});
CHECK(empty.eventIds.empty());
CHECK(empty.summary.find("empty") != std::string::npos);
}
TEST_CASE("devtools.callgraph.nestedStackDepth") {
CallGraph g;
g.onCall(loc("n.nut", 1, "a"), "a");
g.onCall(loc("n.nut", 2, "b"), "b");
g.onCall(loc("n.nut", 3, "c"), "c");
auto deep = g.currentStack();
REQUIRE(deep.size() == 3u);
CHECK_EQ(deep[0].loc.function, std::string("a"));
CHECK_EQ(deep[2].loc.function, std::string("c"));
const uint32_t mid = g.events().back().id;
g.onReturn(loc("n.nut", 4, "c"), "c");
g.onReturn(loc("n.nut", 5, "b"), "b");
CHECK_EQ(g.currentStack().size(), 1u);
auto atMid = g.stackAt(mid);
REQUIRE(atMid.size() == 3u);
CHECK_EQ(atMid[2].loc.function, std::string("c"));
auto edges = g.callEdges();
CHECK_EQ(edges.size(), 2u); // a→b, b→c (returns don't add)
}
TEST_CASE("devtools.callgraph.stackAtInvalid") {
CallGraph g;
CHECK(g.stackAt(0).empty());
CHECK(g.stackAt(42).empty());
g.onCall(loc("s.nut", 1, "main"), "main");
CHECK_EQ(g.stackAt(g.events().back().id).size(), 1u);
}
TEST_CASE("devtools.callgraph.funcNameFallsBackToLoc") {
CallGraph g;
SourceLoc l = loc("f.nut", 1, "fromLoc");
g.onCall(l); // empty funcName → use loc.function
REQUIRE(!g.currentStack().empty());
CHECK_EQ(g.currentStack()[0].loc.function, std::string("fromLoc"));
}
// ---------------------------------------------------------------------------
// Data-flow / slicer
// ---------------------------------------------------------------------------
TEST_CASE("devtools.callgraph.dataFlowReachingDef") {
CallGraph g;
g.onCall(loc("bug.nut", 1, "main"), "main");
g.onDef(loc("bug.nut", 2, "main"), "x");
g.onDef(loc("bug.nut", 3, "main"), "x");
g.onUse(loc("bug.nut", 4, "main"), "x");
g.onLine(loc("bug.nut", 5, "main"));
SliceCriterion c;
c.loc = loc("bug.nut", 5, "main");
c.variables.push_back("x");
SliceResult slice = g.sliceBackward(c);
CHECK(sliceHasLoc(slice, "bug.nut", 3));
CHECK(sliceHasLoc(slice, "bug.nut", 4));
CHECK(dataFlowFromLine(g, slice, "x", 3));
CHECK(!dataFlowFromLine(g, slice, "x", 2));
}
TEST_CASE("devtools.callgraph.multiVariableCriterion") {
CallGraph g;
g.onCall(loc("m.nut", 1, "main"), "main");
g.onDef(loc("m.nut", 2, "main"), "a");
g.onDef(loc("m.nut", 3, "main"), "b");
g.onDef(loc("m.nut", 4, "main"), "c");
g.onUse(loc("m.nut", 5, "main"), "a");
g.onUse(loc("m.nut", 6, "main"), "b");
g.onLine(loc("m.nut", 7, "main"));
SliceCriterion crit;
crit.loc = loc("m.nut", 7, "main");
crit.variables = {"a", "b"};
SliceResult slice = g.sliceBackward(crit);
CHECK(dataFlowFromLine(g, slice, "a", 2));
CHECK(dataFlowFromLine(g, slice, "b", 3));
}
TEST_CASE("devtools.callgraph.eventIdSeed") {
CallGraph g;
g.onCall(loc("id.nut", 1, "main"), "main");
g.onDef(loc("id.nut", 2, "main"), "x");
const uint32_t useId = g.onUse(loc("id.nut", 3, "main"), "x");
g.onDef(loc("id.nut", 4, "main"), "x"); // later def should not shadow seed
g.onLine(loc("id.nut", 5, "main"));
SliceCriterion c;
c.eventId = useId;
c.variables = {"x"};
SliceResult slice = g.sliceBackward(c);
CHECK(dataFlowFromLine(g, slice, "x", 2));
// Seeded at use on line 3; later def on 4 need not be in data-flow of this use.
bool laterDefInData = dataFlowFromLine(g, slice, "x", 4);
CHECK(!laterDefInData);
}
TEST_CASE("devtools.callgraph.useWithoutDef") {
CallGraph g;
g.onCall(loc("u.nut", 1, "main"), "main");
g.onUse(loc("u.nut", 2, "main"), "missing");
g.onLine(loc("u.nut", 3, "main"));
SliceCriterion c;
c.loc = loc("u.nut", 3, "main");
c.variables = {"missing"};
SliceResult slice = g.sliceBackward(c);
CHECK(!slice.eventIds.empty());
CHECK(slice.dataFlow.empty()); // no reaching def
}
TEST_CASE("devtools.callgraph.interProceduralSlice") {
CallGraph g;
g.onCall(loc("game.nut", 1, "main"), "main");
g.onDef(loc("game.nut", 2, "main"), "hp");
g.onCall(loc("game.nut", 3, "main"), "damage");
g.onUse(loc("game.nut", 20, "damage"), "hp");
g.onDef(loc("game.nut", 21, "damage"), "hp");
g.onReturn(loc("game.nut", 22, "damage"), "damage");
g.onUse(loc("game.nut", 4, "main"), "hp");
g.onLine(loc("game.nut", 5, "main"));
SliceCriterion c;
c.loc = loc("game.nut", 5, "main");
c.variables = {"hp"};
SliceResult slice = g.sliceBackward(c);
CHECK(sliceHasLoc(slice, "game.nut", 21));
CHECK(slice.callStack.size() >= 1u);
const std::string report = g.formatErrorReport("assertion failed: hp > 0", c);
CHECK(report.find("Call stack") != std::string::npos);
CHECK(report.find("Data flow") != std::string::npos);
CHECK(report.find("game.nut:21") != std::string::npos);
CHECK(report.find("Vars:") != std::string::npos);
CHECK(report.find("hp") != std::string::npos);
}
TEST_CASE("devtools.callgraph.chainOfAssignments") {
CallGraph g;
g.onCall(loc("ch.nut", 1, "main"), "main");
g.onDef(loc("ch.nut", 2, "main"), "a");
g.onUse(loc("ch.nut", 3, "main"), "a");
g.onDef(loc("ch.nut", 3, "main"), "b"); // b = a
g.onUse(loc("ch.nut", 4, "main"), "b");
g.onDef(loc("ch.nut", 4, "main"), "c"); // c = b
g.onUse(loc("ch.nut", 5, "main"), "c");
g.onLine(loc("ch.nut", 6, "main"));
SliceCriterion c;
c.loc = loc("ch.nut", 6, "main");
c.variables = {"c"};
SliceResult slice = g.sliceBackward(c);
CHECK(dataFlowFromLine(g, slice, "c", 4));
// Control/data may pull b's def; require c's immediate def at least.
CHECK(sliceHasLoc(slice, "ch.nut", 4));
}
TEST_CASE("devtools.callgraph.ignoresUnrelatedCode") {
CallGraph g;
g.onCall(loc("s.nut", 1, "main"), "main");
g.onDef(loc("s.nut", 2, "main"), "a");
g.onDef(loc("s.nut", 3, "main"), "b");
g.onUse(loc("s.nut", 4, "main"), "a");
g.onLine(loc("s.nut", 5, "main"));
SliceCriterion c;
c.loc = loc("s.nut", 5, "main");
c.variables = {"a"};
SliceResult slice = g.sliceBackward(c);
CHECK(sliceHasLoc(slice, "s.nut", 2));
CHECK(sliceHasLoc(slice, "s.nut", 4));
CHECK(dataFlowFromLine(g, slice, "a", 2));
}
TEST_CASE("devtools.callgraph.sliceIncludesCallSite") {
CallGraph g;
g.onCall(loc("cs.nut", 1, "main"), "main");
g.onCall(loc("cs.nut", 2, "main"), "boom");
g.onDef(loc("cs.nut", 10, "boom"), "x");
g.onUse(loc("cs.nut", 11, "boom"), "x");
g.onLine(loc("cs.nut", 12, "boom"));
SliceCriterion c;
c.loc = loc("cs.nut", 12, "boom");
c.variables = {"x"};
SliceResult slice = g.sliceBackward(c);
CHECK(sliceHasKind(g, slice, TraceKind::Call));
CHECK(slice.callStack.size() == 2u);
}
TEST_CASE("devtools.callgraph.locationsAreUnique") {
CallGraph g;
g.onCall(loc("uniq.nut", 1, "main"), "main");
for (int i = 0; i < 5; ++i) {
g.onDef(loc("uniq.nut", 2, "main"), "x");
g.onUse(loc("uniq.nut", 2, "main"), "x");
}
g.onLine(loc("uniq.nut", 3, "main"));
SliceCriterion c;
c.loc = loc("uniq.nut", 3, "main");
SliceResult slice = g.sliceBackward(c);
std::set<std::string> keys;
for (const auto& l : slice.locations) {
const std::string k = l.toString();
CHECK(keys.insert(k).second);
}
}
TEST_CASE("devtools.callgraph.emptyCriterionUsesLastEvent") {
CallGraph g;
g.onCall(loc("last.nut", 1, "main"), "main");
g.onLine(loc("last.nut", 9, "main"));
SliceResult slice = g.sliceBackward({});
CHECK(!slice.eventIds.empty());
CHECK(sliceHasLoc(slice, "last.nut", 9));
}
TEST_CASE("devtools.callgraph.formatReportEmptyStack") {
CallGraph g;
// No calls — only a line
g.onLine(loc("fmt.nut", 1));
SliceCriterion c;
c.loc = loc("fmt.nut", 1);
const std::string report = g.formatErrorReport("oops", c);
CHECK(report.find("Script Error Trace") != std::string::npos);
CHECK(report.find("(empty)") != std::string::npos);
CHECK(report.find("oops") != std::string::npos);
}
TEST_CASE("devtools.callgraph.deepCallThenReturnSlice") {
CallGraph g;
g.onCall(loc("d.nut", 1, "main"), "main");
for (int i = 0; i < 8; ++i) {
char name[16];
std::snprintf(name, sizeof(name), "f%d", i);
g.onCall(loc("d.nut", 10 + i, name), name);
}
g.onDef(loc("d.nut", 100, "f7"), "err");
g.onUse(loc("d.nut", 101, "f7"), "err");
// Unwind all
for (int i = 7; i >= 0; --i) {
char name[16];
std::snprintf(name, sizeof(name), "f%d", i);
g.onReturn(loc("d.nut", 200 + i, name), name);
}
g.onLine(loc("d.nut", 2, "main"));
// Error after unwind — stack is main only; slice by eventId inside f7
uint32_t seed = 0;
for (const auto& e : g.events()) {
if (e.kind == TraceKind::Use && e.name == "err") seed = e.id;
}
REQUIRE(seed != 0u);
SliceCriterion c;
c.eventId = seed;
c.variables = {"err"};
SliceResult slice = g.sliceBackward(c);
CHECK(sliceHasLoc(slice, "d.nut", 100));
CHECK(slice.callStack.size() == 9u); // main + f0..f7
}
// ---------------------------------------------------------------------------
// Ring buffer
// ---------------------------------------------------------------------------
TEST_CASE("devtools.callgraph.ringBufferDropsOne") {
CallGraph g;
g.setMaxEvents(16);
g.onCall(loc("r.nut", 1, "main"), "main");
for (int i = 0; i < 40; ++i) g.onLine(loc("r.nut", 2 + i, "main"));
CHECK_EQ(g.eventCount(), 16u);
CHECK_EQ(g.maxEvents(), 16u);
REQUIRE(!g.events().empty());
const uint32_t newest = g.events().back().id;
const uint32_t oldest = g.events().front().id;
CHECK_EQ(newest - oldest, 15u);
CHECK(g.event(oldest) != nullptr);
CHECK(g.event(oldest - 1) == nullptr);
CHECK(g.event(newest) != nullptr);
}
TEST_CASE("devtools.callgraph.setMaxEventsClampsAndShrinks") {
CallGraph g;
g.setMaxEvents(2); // clamped to 16
CHECK_EQ(g.maxEvents(), 16u);
for (int i = 0; i < 20; ++i) g.onLine(loc("z.nut", i + 1));
CHECK_EQ(g.eventCount(), 16u);
g.setMaxEvents(16);
g.setMaxEvents(20); // grow cap only
CHECK_EQ(g.eventCount(), 16u);
// Shrink: force drop down to 16 min still
g.setMaxEvents(16);
CHECK_EQ(g.eventCount(), 16u);
}
TEST_CASE("devtools.callgraph.ringBufferKeepsRecentDataFlow") {
CallGraph g;
g.setMaxEvents(16);
// Flood with unrelated lines so early defs fall out of the window.
g.onCall(loc("rb.nut", 1, "main"), "main");
g.onDef(loc("rb.nut", 2, "main"), "old");
for (int i = 0; i < 30; ++i) g.onLine(loc("rb.nut", 10 + i, "main"));
g.onDef(loc("rb.nut", 100, "main"), "x");
g.onUse(loc("rb.nut", 101, "main"), "x");
g.onLine(loc("rb.nut", 102, "main"));
CHECK_EQ(g.eventCount(), 16u);
SliceCriterion c;
c.loc = loc("rb.nut", 102, "main");
c.variables = {"x"};
SliceResult slice = g.sliceBackward(c);
CHECK(dataFlowFromLine(g, slice, "x", 100));
// Old def should be gone from the window.
bool sawOld = false;
for (const auto& e : g.events()) {
if (e.kind == TraceKind::Def && e.name == "old") sawOld = true;
}
CHECK(!sawOld);
}
TEST_CASE("devtools.callgraph.ringBufferOneInOneOutInvariant") {
CallGraph g;
g.setMaxEvents(16);
for (int i = 0; i < 16; ++i) g.onLine(loc("inv.nut", i + 1));
CHECK_EQ(g.eventCount(), 16u);
const uint32_t beforeFront = g.events().front().id;
g.onLine(loc("inv.nut", 100));
CHECK_EQ(g.eventCount(), 16u);
CHECK_EQ(g.events().front().id, beforeFront + 1);
CHECK_EQ(g.events().back().loc.line, 100);
}
TEST_CASE("devtools.callgraph.manyFramesSimulateGameLoop") {
CallGraph g;
g.setMaxEvents(64);
for (int frame = 0; frame < 20; ++frame) {
g.onCall(loc("loop.nut", 1, "update"), "update");
g.onDef(loc("loop.nut", 2, "update"), "dt");
g.onUse(loc("loop.nut", 3, "update"), "dt");
g.onReturn(loc("loop.nut", 4, "update"), "update");
}
// Corrupt last frame
g.onCall(loc("loop.nut", 1, "update"), "update");
g.onDef(loc("loop.nut", 2, "update"), "dt");
g.onDef(loc("loop.nut", 5, "update"), "dt"); // bad
g.onUse(loc("loop.nut", 6, "update"), "dt");
g.onLine(loc("loop.nut", 7, "update"));
SliceCriterion c;
c.loc = loc("loop.nut", 7, "update");
c.variables = {"dt"};
SliceResult slice = g.sliceBackward(c);
CHECK(dataFlowFromLine(g, slice, "dt", 5));
CHECK(g.eventCount() <= 64u);
}
TEST_CASE("devtools.callgraph.parentEventIdChain") {
CallGraph g;
g.onCall(loc("p.nut", 1, "main"), "main");
g.onLine(loc("p.nut", 2, "main"));
g.onLine(loc("p.nut", 3, "main"));
REQUIRE(g.events().size() >= 3u);
auto it = g.events().begin();
const uint32_t id0 = it->id;
++it;
const uint32_t id1 = it->id;
CHECK_EQ(it->parentEventId, id0);
++it;
CHECK_EQ(it->parentEventId, id1);
}