Skip to content

Commit 5030341

Browse files
committed
feat: budgeted console formatter (inspect builtin)
console.* now formats objects through internal/inspect.js, a util.inspect-lite compiled once per isolate: depth 2 (console.dir 4), 100 entries per collection, 10k characters per string and a 16KB hard cap, so no single log call can hang the app on a large or cyclic graph. Cycle detection tracks in-progress ancestors only, so shared acyclic references still print in full and only true cycles say [Circular]. Getters are never invoked - accessors render as [Getter]/[Setter] - with two guarded exceptions: error.stack, and a custom toString override, which keeps NativeScript core's Button(42) convention working. All intrinsic access goes through primordials and brands come from a captured Object.prototype.toString, so a tampered prototype cannot change or break the output. binding.getNativeWrapperHint is the Android-specific piece: objects backed by a Java counterpart render as a short [java.lang.Object] hint instead of having their native-backed graph walked, and package objects render as [package java.lang] rather than materializing every class they contain. Detection reads private symbols and internal fields only, so it never runs JS and cannot throw. This replaces the JSON-based path: smart-stringify.js, JsonStringifyObject and transformJSObject are gone, along with console's hand-rolled array printer and console.dir's duplicate dump logic. Logs now look like Node inspect output instead of pretty-printed JSON. If the formatter fails to initialize, logging degrades to ToDetailString rather than throwing. Mirrors NativeScript/ios#416.
1 parent 4687302 commit 5030341

17 files changed

Lines changed: 866 additions & 180 deletions

‎eslint.config.mjs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,20 @@ import globals from 'globals';
1313
// review rule.
1414
const capturedStatics = [
1515
['Array', 'isArray', 'ArrayIsArray'],
16+
['ArrayBuffer', 'isView', 'ArrayBufferIsView'],
1617
['JSON', 'stringify', 'JSONStringify'],
1718
['Object', 'create', 'ObjectCreate'],
1819
['Object', 'defineProperty', 'ObjectDefineProperty'],
20+
['Object', 'getOwnPropertyDescriptor', 'ObjectGetOwnPropertyDescriptor'],
21+
['Object', 'getOwnPropertySymbols', 'ObjectGetOwnPropertySymbols'],
22+
['Object', 'getPrototypeOf', 'ObjectGetPrototypeOf'],
23+
['Object', 'is', 'ObjectIs'],
1924
['Object', 'keys', 'ObjectKeys'],
2025
];
2126

2227
// Captured constructors. A destructure from `primordials` shadows the global,
2328
// so these only fire on the unguarded reference.
24-
const restrictedGlobals = ['Date', 'Map', 'Proxy', 'String', 'TypeError'].map((name) => ({
29+
const restrictedGlobals = ['Date', 'Map', 'Proxy', 'Set', 'String', 'TypeError'].map((name) => ({
2530
name,
2631
message: `Destructure ${name} from primordials — builtins must not read intrinsics off globals user code can replace.`,
2732
}));

‎test-app/app/src/main/assets/app/mainpage.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ require('./tests/testEscapeException');
8080
require('./tests/testUncaughtErrorPolicy');
8181
// Runtime builtins keep working when app code replaces the intrinsics they use
8282
require('./tests/testPrimordials');
83+
require('./tests/testInspect');
8384
require("./tests/testConcurrentAccess");
8485

8586
require("./tests/testESModules.mjs");
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
describe("inspect", function () {
2+
it("formats primitives and plain objects", function () {
3+
expect(__inspect(42)).toBe("42");
4+
expect(__inspect("hi")).toBe('"hi"');
5+
expect(__inspect({ a: 1, b: "x" })).toBe('{ a: 1, b: "x" }');
6+
expect(__inspect([1, [2, 3]])).toBe("[ 1, [ 2, 3 ] ]");
7+
});
8+
9+
it("limits depth", function () {
10+
expect(__inspect({ a: { b: { c: { d: 1 } } } })).toBe("{ a: { b: { c: [Object] } } }");
11+
expect(__inspect({ a: { b: { c: { d: 1 } } } }, { depth: 3 })).toBe("{ a: { b: { c: { d: 1 } } } }");
12+
});
13+
14+
it("reports true cycles and only true cycles", function () {
15+
var cyc = {};
16+
cyc.self = cyc;
17+
expect(__inspect(cyc)).toBe("{ self: [Circular] }");
18+
19+
var shared = { x: 1 };
20+
expect(__inspect({ a: shared, b: shared })).toBe("{ a: { x: 1 }, b: { x: 1 } }");
21+
});
22+
23+
it("caps arrays and total output", function () {
24+
var big = [];
25+
for (var i = 0; i < 250; i++) {
26+
big[i] = i;
27+
}
28+
expect(__inspect(big).indexOf("... 150 more items")).toBeGreaterThan(-1);
29+
30+
var huge = {};
31+
for (var k = 0; k < 100000; k++) {
32+
huge["key" + k] = k;
33+
}
34+
var start = Date.now();
35+
var out = __inspect(huge);
36+
var elapsed = Date.now() - start;
37+
expect(out.length).toBeLessThan(20000);
38+
// The old JSON path would serialize all 100k keys; the budgeted
39+
// formatter must not take anywhere near a second.
40+
expect(elapsed).toBeLessThan(1000);
41+
});
42+
43+
it("caps long strings", function () {
44+
var long = new Array(12001).join("a");
45+
var out = __inspect(long);
46+
expect(out.indexOf("... 2000 more characters")).toBeGreaterThan(-1);
47+
});
48+
49+
it("never invokes getters", function () {
50+
var invoked = false;
51+
var obj = {};
52+
Object.defineProperty(obj, "x", {
53+
enumerable: true,
54+
get: function () {
55+
invoked = true;
56+
return 1;
57+
}
58+
});
59+
expect(__inspect(obj)).toBe("{ x: [Getter] }");
60+
expect(invoked).toBe(false);
61+
});
62+
63+
it("formats collections, dates, regexes, errors and functions", function () {
64+
expect(__inspect(new Map([["k", 1]]))).toBe('Map(1) { "k" => 1 }');
65+
expect(__inspect(new Set([1, 2]))).toBe("Set(2) { 1, 2 }");
66+
expect(__inspect(new Date(0))).toBe("1970-01-01T00:00:00.000Z");
67+
expect(__inspect(/ab+c/gi)).toBe("/ab+c/gi");
68+
expect(__inspect(function foo() {})).toBe("[Function: foo]");
69+
expect(__inspect(class Foo {})).toBe("[class Foo]");
70+
expect(__inspect(new Uint8Array(3))).toBe("Uint8Array(3)");
71+
expect(__inspect(10n)).toBe("10n");
72+
73+
var errOut = __inspect(new Error("boom"));
74+
expect(errOut.indexOf("Error: boom")).toBe(0);
75+
});
76+
77+
it("identifies java objects without walking them", function () {
78+
var out = __inspect(new java.lang.Object());
79+
expect(out.indexOf("[")).toBe(0);
80+
expect(out.indexOf("java.lang.Object")).toBeGreaterThan(-1);
81+
82+
var listOut = __inspect(new java.util.ArrayList());
83+
expect(listOut.indexOf("java.util.ArrayList")).toBeGreaterThan(-1);
84+
85+
// The wrapper hint replaces the whole graph, so a java object nested in
86+
// a plain object stays a single token.
87+
var nested = __inspect({ v: new java.lang.Object() });
88+
expect(nested.indexOf("{ v: [")).toBe(0);
89+
});
90+
91+
it("does not materialize java packages", function () {
92+
// Package children are native data properties, so reading descriptors
93+
// off one would build every class it contains.
94+
var start = Date.now();
95+
var out = __inspect(java);
96+
expect(out.indexOf("[package java")).toBe(0);
97+
expect(__inspect(java.lang).indexOf("[package java.lang")).toBe(0);
98+
expect(Date.now() - start).toBeLessThan(1000);
99+
});
100+
101+
it("renders java classes as callables, not as graphs", function () {
102+
// Class wrappers are constructor functions, so they take the callable
103+
// branch before the native-wrapper hint is consulted.
104+
var out = __inspect(java.lang.Object);
105+
expect(/^\[(Function|class)\b/.test(out)).toBe(true);
106+
});
107+
108+
it("leaves plain javascript objects to structural rendering", function () {
109+
expect(__inspect({ a: 1 })).toBe("{ a: 1 }");
110+
expect(__inspect([1])).toBe("[ 1 ]");
111+
});
112+
113+
it("console.log of a huge cyclic object completes quickly", function () {
114+
var huge = { name: "root" };
115+
var cursor = huge;
116+
for (var i = 0; i < 5000; i++) {
117+
cursor = cursor.child = { i: i, parent: huge };
118+
}
119+
huge.self = huge;
120+
var start = Date.now();
121+
console.log(huge);
122+
expect(Date.now() - start).toBeLessThan(1000);
123+
});
124+
125+
it("honors custom toString overrides (NativeScript core convention)", function () {
126+
function ViewLike() { this.id = 42; }
127+
ViewLike.prototype.toString = function () { return "Button(42)"; };
128+
expect(__inspect(new ViewLike())).toBe("Button(42)");
129+
expect(__inspect({ v: new ViewLike() })).toBe("{ v: Button(42) }");
130+
expect(__inspect({ toString: function () { return "custom!"; } })).toBe("custom!");
131+
// A broken override degrades to structural rendering instead of hiding data.
132+
var broken = { a: 1, toString: function () { throw new Error("x"); } };
133+
expect(__inspect(broken).indexOf("a: 1")).toBeGreaterThan(-1);
134+
});
135+
136+
it("formats under tampered prototypes", function () {
137+
var savedSlice = Array.prototype.slice;
138+
var savedIndexOf = Array.prototype.indexOf;
139+
var savedKeys = Object.keys;
140+
var savedStringify = JSON.stringify;
141+
var boom = function () { throw new Error("tampered"); };
142+
var out;
143+
try {
144+
Array.prototype.slice = boom;
145+
Array.prototype.indexOf = boom;
146+
Object.keys = boom;
147+
JSON.stringify = boom;
148+
out = __inspect({ a: [1, 2], m: new Map([[1, 2]]) });
149+
} finally {
150+
Array.prototype.slice = savedSlice;
151+
Array.prototype.indexOf = savedIndexOf;
152+
Object.keys = savedKeys;
153+
JSON.stringify = savedStringify;
154+
}
155+
expect(out).toBe("{ a: [ 1, 2 ], m: Map(1) { 1 => 2 } }");
156+
});
157+
});
158+

‎test-app/app/src/main/assets/app/tests/testPrimordials.js‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,10 @@ describe("primordials", function () {
118118
});
119119

120120
it("console.log of a circular object neither throws nor crashes with JSON.stringify tampered", function () {
121-
// The smart-stringify builtin both calls JSON.stringify and tracks
122-
// already-visited objects with Array.prototype.indexOf/push. Its output
123-
// is not reachable from JS and JsonStringifyObject swallows a throwing
124-
// stringify, so this only pins down that the tampered path stays
125-
// non-fatal; the primordial routing itself is covered by review.
121+
// The inspect builtin quotes strings through JSON.stringify and builds
122+
// its parts list with Array.prototype.push. Its logcat output is not
123+
// reachable from JS, so this only pins down that the tampered path
124+
// stays non-fatal; testInspect covers the formatting itself.
126125
const circular = { name: "primordials" };
127126
circular.self = circular;
128127

‎test-app/runtime/CMakeLists.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,11 @@ set(RUNTIME_BUILTIN_JS
6767
${RUNTIME_BUILTIN_JS_DIR}/blob-url.js
6868
${RUNTIME_BUILTIN_JS_DIR}/error-events.js
6969
${RUNTIME_BUILTIN_JS_DIR}/events.js
70+
${RUNTIME_BUILTIN_JS_DIR}/inspect.js
7071
${RUNTIME_BUILTIN_JS_DIR}/json-helper.js
7172
${RUNTIME_BUILTIN_JS_DIR}/message-loop-timer.js
7273
${RUNTIME_BUILTIN_JS_DIR}/primordials.js
7374
${RUNTIME_BUILTIN_JS_DIR}/require-factory.js
74-
${RUNTIME_BUILTIN_JS_DIR}/smart-stringify.js
7575
${RUNTIME_BUILTIN_JS_DIR}/weak-ref.js
7676
)
7777
set(RUNTIME_BUILTINS_GENERATED_DIR ${PROJECT_SOURCE_DIR}/src/main/cpp/generated)

‎test-app/runtime/src/main/cpp/BuiltinLoader.cpp‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,9 @@ MaybeLocal<Value> CallBuiltin(Local<Context> context, BuiltinId id, Local<Value>
125125

126126
/*
127127
* Snapshot of the intrinsics, taken the first time any builtin runs in this
128-
* isolate — during runtime init, before user code can replace a global. Later
129-
* builtins (smart-stringify compiles lazily, on the first object logged) get
130-
* the same pristine snapshot.
128+
* isolate — during runtime init, before user code can replace a global.
129+
* Builtins compiled later in the isolate's life get the same pristine
130+
* snapshot.
131131
*/
132132
MaybeLocal<Object> GetPrimordials(Local<Context> context) {
133133
Isolate* isolate = v8::Isolate::GetCurrent();

‎test-app/runtime/src/main/cpp/IsolateDisposer.cpp‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ namespace tns {
1616
void disposeIsolate(v8::Isolate *isolate) {
1717
tns::ArgConverter::onDisposeIsolate(isolate);
1818
tns::MetadataNode::onDisposeIsolate(isolate);
19-
tns::V8GlobalHelpers::onDisposeIsolate(isolate);
2019
tns::Console::onDisposeIsolate(isolate);
2120
tns::JSONObjectHelper::onDisposeIsolate(isolate);
2221
tns::BuiltinLoader::onDisposeIsolate(isolate);

‎test-app/runtime/src/main/cpp/MetadataNode.cpp‎

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,52 @@ void MetadataNode::Init(Isolate* isolate) {
3030
auto key = ArgConverter::ConvertToV8String(isolate, "tns::MetadataKey");
3131
auto cache = GetMetadataNodeCache(isolate);
3232
cache->MetadataKey = new Persistent<String>(isolate, key);
33+
cache->PackageKey = new Persistent<String>(
34+
isolate, ArgConverter::ConvertToV8String(isolate, "tns::PackageKey"));
35+
}
36+
37+
// Deliberately not V8GetPrivateValue: that one resolves the creation context,
38+
// which hard-crashes on values that have none (a revoked proxy), and throws
39+
// when the lookup comes back Nothing. Callers here take arbitrary values.
40+
static void* TryReadPrivateExternal(Isolate* isolate, const Local<Object>& value, Persistent<String>* key) {
41+
if (key == nullptr) {
42+
return nullptr;
43+
}
44+
45+
auto context = isolate->GetCurrentContext();
46+
if (context.IsEmpty()) {
47+
return nullptr;
48+
}
49+
50+
Local<Value> hidden;
51+
auto privateKey = Private::ForApi(isolate, Local<String>::New(isolate, *key));
52+
if (!value->GetPrivate(context, privateKey).ToLocal(&hidden) || !hidden->IsExternal()) {
53+
return nullptr;
54+
}
55+
56+
return hidden.As<External>()->Value(v8::kExternalPointerTypeTagDefault);
57+
}
58+
59+
bool MetadataNode::TryGetInstanceTypeName(Isolate* isolate, const Local<Object>& value, std::string& out) {
60+
auto cache = GetMetadataNodeCache(isolate);
61+
auto node = static_cast<MetadataNode*>(TryReadPrivateExternal(isolate, value, cache->MetadataKey));
62+
if (node == nullptr) {
63+
return false;
64+
}
65+
66+
out = node->m_name;
67+
return true;
68+
}
69+
70+
bool MetadataNode::TryGetPackageName(Isolate* isolate, const Local<Object>& value, std::string& out) {
71+
auto cache = GetMetadataNodeCache(isolate);
72+
auto node = static_cast<MetadataNode*>(TryReadPrivateExternal(isolate, value, cache->PackageKey));
73+
if (node == nullptr) {
74+
return false;
75+
}
76+
77+
out = node->m_name;
78+
return true;
3379
}
3480

3581
Local<ObjectTemplate> MetadataNode::GetOrCreateArrayObjectTemplate(Isolate* isolate) {
@@ -234,10 +280,20 @@ Local<Object> MetadataNode::CreateArrayWrapper(Isolate* isolate) {
234280

235281
Local<Object> MetadataNode::CreatePackageObject(Isolate* isolate) {
236282
auto packageObj = Object::New(isolate);
283+
auto ctx = isolate->GetCurrentContext();
284+
auto extData = External::New(isolate, this, v8::kExternalPointerTypeTagDefault);
285+
286+
// Every child is a native data property, so anything that reads a property
287+
// descriptor off a package object - console formatting, most notably -
288+
// would materialize the whole subtree. The marker lets such callers
289+
// recognize a package and stop.
290+
auto cache = GetMetadataNodeCache(isolate);
291+
packageObj->SetPrivate(ctx,
292+
Private::ForApi(isolate, Local<String>::New(isolate, *cache->PackageKey)),
293+
extData).ToChecked();
294+
237295
auto ptrChildren = this->m_treeNode->children;
238296
if (ptrChildren != nullptr) {
239-
auto ctx = isolate->GetCurrentContext();
240-
auto extData = External::New(isolate, this, v8::kExternalPointerTypeTagDefault);
241297
const auto& children = *ptrChildren;
242298
for (auto childNode: children) {
243299
packageObj->SetNativeDataProperty(ctx,

‎test-app/runtime/src/main/cpp/MetadataNode.h‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@ class MetadataNode {
5959

6060
static std::string GetTypeMetadataName(v8::Isolate* isolate, v8::Local<v8::Value>& value);
6161

62+
/*
63+
* Non-throwing metadata lookups for the console formatter. They read
64+
* private symbols only, so they never re-enter JS or trip an
65+
* interceptor, and they tolerate values carrying no metadata at all
66+
* (unlike GetNodeFromHandle/GetTypeMetadataName, which assume it is
67+
* there). Names come back in the metadata's slashed JNI form.
68+
*/
69+
static bool TryGetInstanceTypeName(v8::Isolate* isolate, const v8::Local<v8::Object>& value, std::string& out);
70+
71+
static bool TryGetPackageName(v8::Isolate* isolate, const v8::Local<v8::Object>& value, std::string& out);
72+
6273
static void onDisposeIsolate(v8::Isolate* isolate);
6374

6475
static MetadataReader* getMetadataReader();
@@ -267,6 +278,8 @@ class MetadataNode {
267278
struct MetadataNodeCache {
268279
v8::Persistent<v8::String>* MetadataKey;
269280

281+
v8::Persistent<v8::String>* PackageKey;
282+
270283
robin_hood::unordered_map<MetadataTreeNode*, CtorCacheData> CtorFuncCache;
271284

272285
robin_hood::unordered_map<std::string, MetadataNode::ExtendedClassCacheData> ExtendedCtorFuncCache;

0 commit comments

Comments
 (0)