unittests/VMRuntime/StackTracesTreeTest.cpp (805 lines of code) (raw):
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
#include "hermes/VM/StackTracesTree.h"
#include "hermes/VM/Runtime.h"
// Enabling Handle-SAN can create additional allocations, which will invalidate
// the expected outputs in this test.
#if defined(HERMES_ENABLE_ALLOCATION_LOCATION_TRACES) && \
!defined(HERMESVM_SANITIZE_HANDLES)
#include "TestHelpers.h"
#include "TestHelpers1.h"
#include "hermes/SourceMap/SourceMap.h"
#include "hermes/SourceMap/SourceMapGenerator.h"
#include "hermes/SourceMap/SourceMapParser.h"
using namespace hermes::vm;
using namespace hermes::parser;
namespace hermes {
namespace unittest {
namespace stacktracestreetest {
namespace {
struct StackTracesTreeTest : public RuntimeTestFixtureBase {
explicit StackTracesTreeTest()
: RuntimeTestFixtureBase(
RuntimeConfig::Builder(kTestRTConfigBuilder)
.withES6Promise(true)
.withES6Proxy(true)
.withIntl(true)
.withGCConfig(GCConfig::Builder(kTestGCConfigBuilder).build())
.build()) {
runtime.enableAllocationLocationTracker();
}
explicit StackTracesTreeTest(const RuntimeConfig &config)
: RuntimeTestFixtureBase(config) {}
::testing::AssertionResult eval(const std::string &code) {
hbc::CompileFlags flags;
// Ideally none of this should require debug info, so let's ensure it
// doesn't.
flags.debug = false;
auto runRes = runtime.run(code, "eval.js", flags);
return isException(runRes);
};
::testing::AssertionResult checkTraceMatches(
const std::string &code,
const std::string &expectedTrace) {
hbc::CompileFlags flags;
flags.debug = false;
auto runRes = runtime.run(code, "test.js", flags);
if (isException(runRes)) {
return isException(runRes);
}
if (!runRes->isPointer()) {
return ::testing::AssertionFailure()
<< "Returned value was not a HV with a pointer";
}
std::string res;
llvh::raw_string_ostream resStream(res);
auto stringTable = runtime.getStackTracesTree()->getStringTable();
auto &allocationLocationTracker =
runtime.getHeap().getAllocationLocationTracker();
auto node = allocationLocationTracker.getStackTracesTreeNodeForAlloc(
runtime.getHeap().getObjectID(
static_cast<GCCell *>(runRes->getPointer())));
while (node) {
resStream << (*stringTable)[node->name] << " "
<< (*stringTable)[node->sourceLoc.scriptName] << ":"
<< node->sourceLoc.lineNo << ":" << node->sourceLoc.columnNo
<< "\n";
node = node->parent;
}
resStream.flush();
auto trimmedRes = llvh::StringRef(res).trim();
return trimmedRes == expectedTrace
? ::testing::AssertionSuccess()
: (::testing::AssertionFailure()
<< "Expected trace:\n"
<< expectedTrace.c_str() << "\nActual trace:\n"
<< trimmedRes.str().c_str());
};
::testing::AssertionResult runWithSourceMap(
const std::string &code,
SourceMapGenerator &sourceMapGen) {
RuntimeModuleFlags runtimeModuleFlags;
runtimeModuleFlags.persistent = false;
std::vector<uint8_t> bytecode =
bytecodeForSource(code.c_str(), TestCompileFlags{}, &sourceMapGen);
std::shared_ptr<hbc::BCProviderFromBuffer> bcProvider =
hbc::BCProviderFromBuffer::createBCProviderFromBuffer(
std::make_unique<Buffer>(&bytecode[0], bytecode.size()))
.first;
auto runRes = runtime.runBytecode(
std::move(bcProvider),
runtimeModuleFlags,
"test.js.hbc",
vm::Runtime::makeNullHandle<vm::Environment>());
if (isException(runRes)) {
return isException(runRes);
}
if (!runRes->isPointer()) {
return ::testing::AssertionFailure()
<< "Returned value was not a HV with a pointer";
}
return ::testing::AssertionSuccess();
}
/// Serialize and immediately parse the source map generated by
/// \p sourceMapGen.
std::unique_ptr<SourceMap> getSourceMap(SourceMapGenerator &sourceMapGen) {
std::string res;
llvh::raw_string_ostream resStream(res);
SourceErrorManager sm;
sourceMapGen.outputAsJSON(resStream);
auto sourceMap = SourceMapParser::parse(res, sm);
assert(
sm.getErrorCount() == 0 && "source map generation or parsing failed");
return sourceMap;
}
};
// Used to inject a no-op function into JS.
static CallResult<HermesValue> noop(void *, Runtime &runtime, NativeArgs) {
return HermesValue::encodeUndefinedValue();
}
static CallResult<HermesValue>
enableAllocationLocationTracker(void *, Runtime &runtime, NativeArgs) {
runtime.enableAllocationLocationTracker();
// syncWithRuntimeStack adds a native stack frame here, but the interpreter
// doesn't pop that frame. This seems to only be a problem if
// enableAllocationLocationTracker is called in a native callback within
// the JS stack.
// In practice, it is only ever called by the Chrome inspector, so this
// case isn't important to fix.
runtime.getStackTracesTree()->popCallStack();
return HermesValue::encodeUndefinedValue();
}
struct StackTracesTreeParameterizedTest
: public StackTracesTreeTest,
public ::testing::WithParamInterface<bool> {
StackTracesTreeParameterizedTest()
: StackTracesTreeTest(
RuntimeConfig::Builder(kTestRTConfigBuilder)
.withES6Proxy(true)
.withIntl(true)
.withGCConfig(GCConfig::Builder(kTestGCConfigBuilder).build())
.build()) {
if (trackerOnByDefault()) {
runtime.enableAllocationLocationTracker();
}
}
bool trackerOnByDefault() const {
// If GetParam() is true, then allocation tracking is enabled from the
// start. If GetParam() is false, then allocation tracking begins when
// enableAllocationLocationTracker is called.
return GetParam();
}
/// Delete the existing tree and reset all state related to allocations.
void resetTree() {
// Calling this should clear all existing StackTracesTree data.
runtime.disableAllocationLocationTracker(true);
ASSERT_FALSE(runtime.getStackTracesTree());
// If the tracker was on by default, after cleaning it should be re-enabled,
// so the function doesn't need to be called.
if (trackerOnByDefault()) {
runtime.enableAllocationLocationTracker();
}
}
void SetUp() override {
// Add a JS function 'enableAllocationLocationTracker'
// The stack traces for objects allocated after the call to
// enableAllocationLocationTracker should be identical.
SymbolID enableAllocationLocationTrackerSym;
{
vm::GCScope gcScope(runtime);
enableAllocationLocationTrackerSym =
vm::stringToSymbolID(
runtime,
vm::StringPrimitive::createNoThrow(
runtime, "enableAllocationLocationTracker"))
->getHermesValue()
.getSymbol();
}
ASSERT_FALSE(isException(JSObject::putNamed_RJS(
runtime.getGlobal(),
runtime,
enableAllocationLocationTrackerSym,
runtime.makeHandle<NativeFunction>(
*NativeFunction::createWithoutPrototype(
runtime,
nullptr,
trackerOnByDefault() ? noop : enableAllocationLocationTracker,
enableAllocationLocationTrackerSym,
0)))));
}
// No need for a tear-down, because the runtime destructor will clear all
// memory.
};
} // namespace
static std::string stackTraceToJSON(
StackTracesTree &tree,
const SourceMap *sourceMap = nullptr) {
auto &stringTable = *tree.getStringTable();
std::string res;
llvh::raw_string_ostream stream(res);
JSONEmitter json(stream, /* pretty */ true);
llvh::SmallVector<StackTracesTreeNode *, 128> nodeStack;
nodeStack.push_back(tree.getRootNode());
while (!nodeStack.empty()) {
auto curNode = nodeStack.pop_back_val();
if (!curNode) {
json.closeArray();
json.closeDict();
continue;
}
llvh::StringRef scriptName{stringTable[curNode->sourceLoc.scriptName]};
if (scriptName.contains("InternalBytecode")) {
continue;
}
json.openDict();
json.emitKeyValue("name", stringTable[curNode->name]);
llvh::Optional<SourceMapTextLocation> mappedLoc;
if (sourceMap && scriptName.contains("test.js.hbc")) {
mappedLoc = sourceMap->getLocationForAddress(
curNode->sourceLoc.lineNo, curNode->sourceLoc.columnNo);
}
if (mappedLoc.hasValue()) {
json.emitKeyValue("scriptName", mappedLoc->fileName);
json.emitKeyValue("line", mappedLoc->line);
json.emitKeyValue("col", mappedLoc->column);
} else {
json.emitKeyValue("scriptName", scriptName);
json.emitKeyValue("line", curNode->sourceLoc.lineNo);
json.emitKeyValue("col", curNode->sourceLoc.columnNo);
}
json.emitKey("children");
json.openArray();
nodeStack.push_back(nullptr);
for (auto child : curNode->getChildren()) {
nodeStack.push_back(child);
}
}
stream.flush();
return res;
}
#define ASSERT_RUN_TRACE(code, trace) \
ASSERT_TRUE( \
checkTraceMatches(code, llvh::StringRef(trace).trim().str().c_str())); \
ASSERT_TRUE(runtime.getStackTracesTree()->isHeadAtRoot())
TEST_F(StackTracesTreeTest, BasicOperation) {
ASSERT_RUN_TRACE(
"function bar() {return new Object();}; function foo() {return bar();}; foo();",
R"#(
bar test.js:1:34
foo test.js:1:66
global test.js:1:75
global test.js:1:1
(root) :0:0
)#");
const auto expectedTree = llvh::StringRef(R"#(
{
"name": "(root)",
"scriptName": "",
"line": 0,
"col": 0,
"children": [
{
"name": "global",
"scriptName": "test.js",
"line": 1,
"col": 1,
"children": [
{
"name": "global",
"scriptName": "test.js",
"line": 1,
"col": 75,
"children": [
{
"name": "foo",
"scriptName": "test.js",
"line": 1,
"col": 66,
"children": [
{
"name": "bar",
"scriptName": "test.js",
"line": 1,
"col": 34,
"children": []
},
{
"name": "bar",
"scriptName": "test.js",
"line": 1,
"col": 1,
"children": []
}
]
},
{
"name": "foo",
"scriptName": "test.js",
"line": 1,
"col": 40,
"children": []
}
]
},
{
"name": "global",
"scriptName": "test.js",
"line": 1,
"col": 1,
"children": []
}
]
}
]
}
)#")
.trim();
auto stackTracesTree = runtime.getStackTracesTree();
ASSERT_TRUE(stackTracesTree);
ASSERT_STREQ(
stackTraceToJSON(*stackTracesTree).c_str(), expectedTree.str().c_str());
}
TEST_P(StackTracesTreeParameterizedTest, GlobalScopeAlloc) {
// Not only should the trace be correct but the stack trace should be
// popped back down to the root. This is implicitly checked by
// ASSERT_RUN_TRACE.
ASSERT_RUN_TRACE(
R"#(
enableAllocationLocationTracker();
new Object();
)#",
R"#(
global test.js:3:11
global test.js:2:1
(root) :0:0
)#");
}
TEST_P(StackTracesTreeParameterizedTest, TraceThroughNamedAnon) {
ASSERT_RUN_TRACE(
R"#(
function foo() {
function bar() {
var anonVar = function() {
enableAllocationLocationTracker();
return new Object();
}
return anonVar();
}
return bar();
}
foo();
)#",
R"#(
anonVar test.js:6:24
bar test.js:8:19
foo test.js:10:13
global test.js:12:4
global test.js:2:1
(root) :0:0
)#");
}
TEST_P(StackTracesTreeParameterizedTest, TraceThroughAnon) {
ASSERT_RUN_TRACE(
R"#(
function foo() {
return (function() {
enableAllocationLocationTracker();
return new Object();
})();
}
foo();
)#",
R"#(
(anonymous) test.js:5:22
foo test.js:6:5
global test.js:8:4
global test.js:2:1
(root) :0:0
)#");
}
TEST_P(StackTracesTreeParameterizedTest, TraceThroughAssignedFunction) {
ASSERT_RUN_TRACE(
R"#(
function foo() {
enableAllocationLocationTracker();
return new Object();
}
var bar = foo;
bar();
)#",
R"#(
foo test.js:4:20
global test.js:7:4
global test.js:2:1
(root) :0:0
)#");
}
TEST_P(StackTracesTreeParameterizedTest, TraceThroughGetter) {
ASSERT_RUN_TRACE(
R"#(
const obj = {
get foo() {
enableAllocationLocationTracker();
return new Object();
}
}
obj.foo;
)#",
R"#(
get foo test.js:5:22
global test.js:8:4
global test.js:2:1
(root) :0:0
)#");
}
TEST_P(StackTracesTreeParameterizedTest, TraceThroughProxy) {
ASSERT_RUN_TRACE(
R"#(
const handler = {
get: function(obj, prop) {
enableAllocationLocationTracker();
return new Object();
}
};
const p = new Proxy({}, handler);
p.something;
)#",
R"#(
get test.js:5:22
global test.js:9:2
global test.js:2:1
(root) :0:0
)#");
}
TEST_P(StackTracesTreeParameterizedTest, TraceThroughEval) {
ASSERT_RUN_TRACE(
R"#(
function returnit() {
enableAllocationLocationTracker();
return new Object();
}
eval("returnit()");
)#",
R"#(
returnit test.js:4:20
eval JavaScript:1:9
global test.js:6:5
global test.js:2:1
(root) :0:0
)#");
}
TEST_P(StackTracesTreeParameterizedTest, TraceThroughBoundFunctions) {
ASSERT_FALSE(eval(
R"#(
function foo() {
enableAllocationLocationTracker();
return new Object();
})#"));
ASSERT_RUN_TRACE("foo.bind(null)()", R"#(
foo eval.js:4:20
global test.js:1:15
global test.js:1:1
(root) :0:0
)#");
resetTree();
ASSERT_RUN_TRACE("foo.bind(null).bind(null)()", R"#(
foo eval.js:4:20
global test.js:1:26
global test.js:1:1
(root) :0:0
)#");
resetTree();
ASSERT_RUN_TRACE(
R"#(
function chain1() {
return chain2bound();
}
function chain2() {
enableAllocationLocationTracker();
return new Object();
}
var chain2bound = chain2.bind(null);
chain1.bind(null)();
)#",
R"#(
chain2 test.js:8:20
chain1 test.js:3:21
global test.js:13:18
global test.js:2:1
(root) :0:0
)#");
resetTree();
}
TEST_P(StackTracesTreeParameterizedTest, TraceThroughNative) {
ASSERT_RUN_TRACE(
R"#(
function foo(x) {
enableAllocationLocationTracker();
return new Object();
}
([0].map(foo))[0];
)#",
R"#(
foo test.js:4:20
global test.js:6:9
global test.js:2:1
(root) :0:0
)#");
}
TEST_P(StackTracesTreeParameterizedTest, UnwindOnThrow) {
// This relies on ASSERT_RUN_TRACE implicitly checking the stack is cleared
ASSERT_RUN_TRACE(
R"#(
function foo() {
try {
function throws() {
enableAllocationLocationTracker();
throw new Error();
}
([0].map(throws.bind(null)))[0];
} catch(e) {
return e;
}
return false;
}
foo();
)#",
R"#(
throws test.js:6:22
foo test.js:8:13
global test.js:14:4
global test.js:2:1
(root) :0:0
)#");
resetTree();
// Test catching multiple blocks up.
ASSERT_RUN_TRACE(
R"#(
function thrower() {
enableAllocationLocationTracker();
throw new Error();
}
function layerOne() { return thrower(); }
function layerTwo() { return layerOne(); }
function tryAlloc() {
try {
layerTwo();
} catch (e) {
return e;
}
}
tryAlloc();
)#",
R"#(
thrower test.js:4:18
layerOne test.js:6:37
layerTwo test.js:7:38
tryAlloc test.js:10:13
global test.js:15:9
global test.js:2:1
(root) :0:0
)#");
}
TEST_P(StackTracesTreeParameterizedTest, MultipleNativeLayers) {
// Multiple map and bind layers.
ASSERT_RUN_TRACE(
R"#(
function foo() {
enableAllocationLocationTracker();
return new Object();
}
([0].map(foo.bind(null)))[0];
)#",
R"#(
foo test.js:4:20
global test.js:6:9
global test.js:2:1
(root) :0:0
)#");
resetTree();
// Multiple Function.prototype.apply layers.
ASSERT_RUN_TRACE(
R"#(
function foo() {
enableAllocationLocationTracker();
return new Object();
}
function secondLayerApply() { return foo.apply(null, []); }
function layered() { return secondLayerApply(); }
function fooApply() { return layered.apply(null, []); }
fooApply();
)#",
R"#(
foo test.js:4:20
secondLayerApply test.js:6:47
layered test.js:7:45
fooApply test.js:8:43
global test.js:9:9
global test.js:2:1
(root) :0:0
)#");
resetTree();
}
// Test with the allocation location tracker on and off.
INSTANTIATE_TEST_CASE_P(
WithOrWithoutAllocationTracker,
StackTracesTreeParameterizedTest,
::testing::Bool());
TEST_F(StackTracesTreeTest, MultipleAllocationsMergeInTree) {
ASSERT_FALSE(eval(R"#(
function foo() {
return new Object();
}
function bar(a) {
for (var i = 0; i < a[1]; i++) {
a[0]();
}
}
function baz() {
return new Object();
}
[[foo, 1], [foo, 10], [baz, 1]].map(bar);
)#"));
const auto expectedTree = llvh::StringRef(R"#(
{
"name": "(root)",
"scriptName": "",
"line": 0,
"col": 0,
"children": [
{
"name": "global",
"scriptName": "eval.js",
"line": 3,
"col": 1,
"children": [
{
"name": "global",
"scriptName": "eval.js",
"line": 14,
"col": 36,
"children": [
{
"name": "bar",
"scriptName": "eval.js",
"line": 8,
"col": 9,
"children": [
{
"name": "baz",
"scriptName": "eval.js",
"line": 12,
"col": 20,
"children": []
},
{
"name": "baz",
"scriptName": "eval.js",
"line": 11,
"col": 1,
"children": []
},
{
"name": "foo",
"scriptName": "eval.js",
"line": 4,
"col": 20,
"children": []
},
{
"name": "foo",
"scriptName": "eval.js",
"line": 3,
"col": 1,
"children": []
}
]
},
{
"name": "bar",
"scriptName": "eval.js",
"line": 6,
"col": 1,
"children": []
}
]
},
{
"name": "global",
"scriptName": "eval.js",
"line": 14,
"col": 24,
"children": []
},
{
"name": "global",
"scriptName": "eval.js",
"line": 14,
"col": 13,
"children": []
},
{
"name": "global",
"scriptName": "eval.js",
"line": 14,
"col": 2,
"children": []
},
{
"name": "global",
"scriptName": "eval.js",
"line": 14,
"col": 3,
"children": []
},
{
"name": "global",
"scriptName": "eval.js",
"line": 3,
"col": 1,
"children": []
}
]
}
]
}
)#")
.trim();
auto stackTracesTree = runtime.getStackTracesTree();
ASSERT_TRUE(stackTracesTree);
ASSERT_STREQ(
stackTraceToJSON(*stackTracesTree).c_str(), expectedTree.str().c_str());
}
TEST_F(StackTracesTreeTest, WithSourceMap) {
SourceMapGenerator sourceMapGen;
ASSERT_TRUE(runWithSourceMap(
R"#(
function bar() {
return new Object();
};
function foo() {
return bar();
};
foo();
)#",
sourceMapGen));
auto sourceMap = getSourceMap(sourceMapGen);
// NOTE: This tree has duplicate nodes because some bytecode addresses map to
// the same source location.
const auto expectedTree = llvh::StringRef(R"#(
{
"name": "(root)",
"scriptName": "",
"line": 0,
"col": 0,
"children": [
{
"name": "global",
"scriptName": "JavaScript",
"line": 2,
"col": 1,
"children": [
{
"name": "global",
"scriptName": "JavaScript",
"line": 8,
"col": 4,
"children": [
{
"name": "foo",
"scriptName": "JavaScript",
"line": 6,
"col": 13,
"children": [
{
"name": "bar",
"scriptName": "JavaScript",
"line": 3,
"col": 20,
"children": []
},
{
"name": "bar",
"scriptName": "JavaScript",
"line": 2,
"col": 1,
"children": []
}
]
},
{
"name": "foo",
"scriptName": "JavaScript",
"line": 5,
"col": 1,
"children": []
}
]
},
{
"name": "global",
"scriptName": "JavaScript",
"line": 2,
"col": 1,
"children": []
},
{
"name": "global",
"scriptName": "JavaScript",
"line": 2,
"col": 1,
"children": []
},
{
"name": "global",
"scriptName": "JavaScript",
"line": 2,
"col": 1,
"children": []
},
{
"name": "global",
"scriptName": "JavaScript",
"line": 2,
"col": 1,
"children": []
}
]
}
]
}
)#")
.trim();
auto stackTracesTree = runtime.getStackTracesTree();
ASSERT_TRUE(stackTracesTree);
ASSERT_STREQ(
stackTraceToJSON(*stackTracesTree, sourceMap.get()).c_str(),
expectedTree.str().c_str());
}
} // namespace stacktracestreetest
} // namespace unittest
} // namespace hermes
#endif // HERMES_ENABLE_ALLOCATION_LOCATION_TRACES