diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d88aad..e12fd32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +### Bug Fixes 🐛 + +- Limit the serialization of `asyncState` and `pollState` (depth, properties + per object, total reads and total string length, with markers such as + `"[Object]"` and `"[MaxProperties ~]"` where data was cut), so capturing a + stack trace no longer freezes, crashes or runs out of memory on large or + deeply linked state. Exceptions thrown while reading the state no longer + reach the captured thread, only own properties are serialized, and `Proxy` + values become `"[Proxy]"`. + ## 0.5.2 ### Bug Fixes 🐛 diff --git a/README.md b/README.md index 5f2ed0c..f6f02f6 100644 --- a/README.md +++ b/README.md @@ -233,3 +233,37 @@ Sends a heartbeat from the current thread. Returns the time in milliseconds since each registered thread called `threadPoll()`. + +### State serialization + +`asyncState` and `pollState` are best-effort, size-limited snapshots. +`asyncState` is read on the captured thread while its JavaScript is +interrupted, so the serialization is limited whatever the state contains: + +- Values are copied like `JSON.stringify` copies them: own enumerable + string-keyed properties only, while functions, symbols, BigInts, `undefined` + and circular references are left out. Shared references that are not circular + are copied each time they appear. Integer-like keys of plain objects (e.g. + `"0"`) are left out, and `toJSON` methods are not called. +- Getters run, as they do with `JSON.stringify`. A property whose getter throws + is left out. Exceptions never reach the code running on the captured thread, + but termination (e.g. `worker.terminate()`) still takes effect. A `Proxy` + becomes `"[Proxy]"` without running its traps. +- Limits, with markers replacing what was cut: + - Depth 10: objects and arrays nested deeper become `"[Object]"` or + `"[Array]"`. + - 1,000 properties or elements per object or array: the rest is replaced by a + single `"[MaxProperties ~]"` entry (the next element of an array, or the + value of the first key left out of an object). + - 20,000 property or element reads in total: after that nothing more is read. + The objects and arrays being copied end with a `"[MaxProperties ~]"` entry, + and later objects and arrays become `"[Object]"` or `"[Array]"`. + - 1,048,576 characters (UTF-16 code units) of strings in total, counting + values and keys: the string that crosses the limit is cut and ends with + `"..."`, and later strings become `"[String]"`. An object whose next key + does not fit ends with a `"...": "[MaxProperties ~]"` entry. +- The limits bound the size of the snapshot and how deep the serialization + recurses. The serialization still reads the full list of own keys of each + object it copies, and still runs getters, so a slow getter or a very wide + object delays the snapshot. +- If the state cannot be read at all, `asyncState` is left out. diff --git a/module.cc b/module.cc index 3cf6799..5eb5635 100644 --- a/module.cc +++ b/module.cc @@ -21,6 +21,10 @@ #define SUPPORTS_ASYNC_CONTEXT_FRAME NODE_MAJOR_VERSION >= 22 #define GET_CONTINUATION_PRESERVED_EMBEDDER_DATA_V2 V8_MAJOR_VERSION >= 14 +// String::WriteV2 and Object::SetPrototypeV2 replace String::Write and +// Object::SetPrototype, which newer V8 versions no longer provide +#define USE_V8_API_V2 \ + (V8_MAJOR_VERSION > 13 || (V8_MAJOR_VERSION == 13 && V8_MINOR_VERSION >= 6)) using namespace v8; using namespace node; @@ -28,6 +32,32 @@ using namespace std::chrono; static const int kMaxStackFrames = 50; +// Limits for serializing thread state (AsyncLocalStorage state and threadPoll +// state). The AsyncLocalStorage state is serialized on the captured thread +// while its JavaScript is interrupted, so the work must stay bounded whatever +// the shape of the object graph. +// +// Maximum nesting depth of objects/arrays. The root value is at depth 0, and +// objects/arrays at this depth are replaced by "[Object]"/"[Array]". Sentry +// keeps breadcrumb data at isolationScope._breadcrumbs[i].data (depth 4), so +// five levels below `data` are kept (Sentry normalizes events to depth 3). +static const size_t kMaxStateDepth = 10; +// Maximum number of properties/elements copied per object/array. The rest is +// replaced by a single "[MaxProperties ~]" entry. +static const uint32_t kMaxStateBreadth = 1000; +// Maximum number of properties/elements read per serialization, including +// reads that throw. Shared references are copied once per path, which can grow +// exponentially without this budget. Once it is used up, nothing more is read: +// the objects/arrays being copied end with a "[MaxProperties ~]" entry. +static const uint32_t kMaxStateNodes = 20000; +// Maximum total length (UTF-16 code units) of the strings copied per +// serialization, counting string values and property keys. Without it, one +// large string referenced many times is copied every time. A string value that +// does not fit is cut to the rest of the budget plus "...", later string values +// become "[String]", and an object whose next key does not fit ends with a +// "...": "[MaxProperties ~]" entry. +static const uint32_t kMaxStateStringLength = 1024 * 1024; + struct AsyncLocalStorageLookup { // Async local storage instance associated with this thread v8::Global async_local_storage; @@ -93,18 +123,134 @@ struct ThreadResult { std::string poll_state; }; +// Traversal state of one SanitizeForJSON walk +struct SanitizeState { + explicit SanitizeState(v8::TryCatch &try_catch) : try_catch(try_catch) {} + + // Catches exceptions thrown by getters during the walk + v8::TryCatch &try_catch; + // Objects/arrays on the current path, used for cycle detection. Its size is + // the current depth, so it never exceeds kMaxStateDepth. + std::vector> ancestors; + // Properties/elements that may still be read, see kMaxStateNodes + uint32_t remaining_nodes = kMaxStateNodes; + // String length that may still be copied, see kMaxStateStringLength + uint32_t remaining_string_length = kMaxStateStringLength; + // Set when execution is terminating; the walk must not call into V8 anymore + bool terminating = false; +}; + +// Creates one of the marker strings that replace cut-off data +template +static v8::Local Marker(v8::Isolate *isolate, + const char (&text)[N]) { + return v8::String::NewFromUtf8Literal(isolate, text, + v8::NewStringType::kInternalized); +} + +// Handles a failed V8 call during the walk: clears the exception so the walk +// can continue without the value. If execution is terminating, the exception +// is kept and the walk stops (see KeepTermination). +static void RecoverFromException(SanitizeState &state) { + if (!state.try_catch.CanContinue()) { + state.terminating = true; + return; + } + state.try_catch.Reset(); +} + +// Takes one read from the budget before a property/element is read. Returns +// false once the budget is used up. +static bool TakeNode(SanitizeState &state) { + if (state.remaining_nodes == 0) { + return false; + } + state.remaining_nodes--; + return true; +} + +// An execution termination (e.g. from worker.terminate()) caught by a TryCatch +// does not survive it: the thread would keep running. This cancels the caught +// termination and requests it again, so it takes effect as soon as JavaScript +// runs again on this thread. Ordinary exceptions stay caught. Call this last +// in the TryCatch's scope. +static void KeepTermination(v8::Isolate *isolate, v8::TryCatch &try_catch) { + if (!try_catch.HasTerminated() && !isolate->IsExecutionTerminating()) { + return; + } + isolate->CancelTerminateExecution(); + try_catch.Reset(); + isolate->TerminateExecution(); +} + +// Returns the string if it fits into the remaining string budget. Otherwise +// returns the part that still fits followed by "...", or "[String]" once the +// budget is used up. Only the part that is kept is copied (V8 may flatten a +// concatenated string first). +static v8::Local SanitizeString(v8::Isolate *isolate, + v8::Local str, + SanitizeState &state) { + auto length = static_cast(str->Length()); + if (length <= state.remaining_string_length) { + state.remaining_string_length -= length; + return str; + } + + auto prefix_length = state.remaining_string_length; + state.remaining_string_length = 0; + if (prefix_length == 0) { + return Marker(isolate, "[String]"); + } + + std::vector buffer(prefix_length); +#if USE_V8_API_V2 + str->WriteV2(isolate, 0, prefix_length, buffer.data()); +#else + str->Write(isolate, buffer.data(), 0, static_cast(prefix_length), + v8::String::NO_NULL_TERMINATION); +#endif + // Do not split a surrogate pair + auto last = buffer[prefix_length - 1]; + if (last >= 0xD800 && last <= 0xDBFF) { + prefix_length--; + } + + v8::Local prefix; + if (!v8::String::NewFromTwoByte(isolate, buffer.data(), + v8::NewStringType::kNormal, + static_cast(prefix_length)) + .ToLocal(&prefix)) { + return Marker(isolate, "[String]"); + } + return v8::String::Concat(isolate, prefix, Marker(isolate, "...")); +} + // Recursively sanitize a value to be safely JSON-stringifiable by: // - Removing properties whose values are BigInt, Function, or Symbol // (dropped for objects, omitted from arrays) // - Breaking cycles by omitting repeated objects (undefined -> dropped/omitted) -// - Preserving primitives and traversing arrays/objects -static v8::Local -SanitizeForJSON(v8::Isolate *isolate, v8::Local context, - v8::Local value, - std::vector> &ancestors) { +// - Dropping values that throw when read +// - Preserving primitives and traversing arrays/objects (own enumerable +// string-keyed properties only) +// - Replacing Proxies by "[Proxy]" without running their traps +// - Bounding the work: objects/arrays nested kMaxStateDepth deep, or reached +// once kMaxStateNodes reads are used up, are replaced by +// "[Object]"/"[Array]", and objects/arrays cut by kMaxStateBreadth, +// kMaxStateNodes or kMaxStateStringLength end with a "[MaxProperties ~]" +// entry +// The copies have no prototype, so JSON::Stringify finds no inherited toJSON +// on them, and are built with CreateDataProperty, which runs no setters. +// Returns undefined if the walk stopped because execution is terminating. +static v8::Local SanitizeForJSON(v8::Isolate *isolate, + v8::Local context, + v8::Local value, + SanitizeState &state) { + if (value->IsString()) { + return SanitizeString(isolate, value.As(), state); + } + // Fast-path for primitives that are always JSON-compatible - if (value->IsNull() || value->IsBoolean() || value->IsNumber() || - value->IsString()) { + if (value->IsNull() || value->IsBoolean() || value->IsNumber()) { return value; } @@ -116,110 +262,165 @@ SanitizeForJSON(v8::Isolate *isolate, v8::Local context, return v8::Undefined(isolate); } - // Arrays - if (value->IsArray()) { - auto arr = value.As(); - // Cycle detection - auto arr_obj = value.As(); - for (auto &a : ancestors) { - if (a->StrictEquals(arr_obj)) { - return v8::Undefined(isolate); - } + // Fallback: return as-is (shouldn't hit here for other exotic types) + if (!value->IsObject()) { + return value; + } + + // Reading a Proxy's keys or values would run its traps + if (value->IsProxy()) { + return Marker(isolate, "[Proxy]"); + } + + // Arrays and objects (including Dates, RegExps, Maps as objects; we only + // traverse enumerable own props) + auto obj = value.As(); + // Cycle detection + for (auto &a : state.ancestors) { + if (a->StrictEquals(obj)) { + return v8::Undefined(isolate); } + } + + auto is_array = value->IsArray(); + if (state.ancestors.size() >= kMaxStateDepth || state.remaining_nodes == 0) { + return is_array ? Marker(isolate, "[Array]") : Marker(isolate, "[Object]"); + } + v8::Local result = v8::Undefined(isolate); + state.ancestors.push_back(obj); + + if (is_array) { + auto arr = value.As(); auto length = arr->Length(); auto out = v8::Array::New(isolate, 0); - ancestors.push_back(arr_obj); +#if USE_V8_API_V2 + out->SetPrototypeV2(context, v8::Null(isolate)).FromMaybe(false); +#else + out->SetPrototype(context, v8::Null(isolate)).FromMaybe(false); +#endif uint32_t out_index = 0; - for (uint32_t i = 0; i < length; ++i) { - auto maybeEl = arr->Get(context, i); + uint32_t i = 0; + for (; i < length && i < kMaxStateBreadth && !state.terminating; ++i) { + if (!TakeNode(state)) { + break; + } + v8::Local el; - if (!maybeEl.ToLocal(&el)) { - el = v8::Undefined(isolate); + if (!arr->Get(context, i).ToLocal(&el)) { + RecoverFromException(state); + continue; // omit entries that cannot be read } - auto sanitized = SanitizeForJSON(isolate, context, el, ancestors); + auto sanitized = SanitizeForJSON(isolate, context, el, state); if (!sanitized->IsUndefined()) { - out->Set(context, out_index++, sanitized) - .Check(); // omit undefined entries entirely - } - } - ancestors.pop_back(); - return out; - } - - // Objects (including Dates, RegExps, Maps as objects; we only traverse - // enumerable own props) - if (value->IsObject()) { - auto obj = value.As(); - // Cycle detection - for (auto &a : ancestors) { - if (a->StrictEquals(obj)) { - return v8::Undefined(isolate); + out->CreateDataProperty(context, out_index++, sanitized) + .FromMaybe(false); // omit undefined entries entirely } } - ancestors.push_back(obj); - - // Collect own enumerable property names (string-keyed) - auto maybe_props = obj->GetPropertyNames(context); - if (maybe_props.IsEmpty()) { - ancestors.pop_back(); - return obj; // Nothing enumerable to sanitize + if (i < length && !state.terminating) { + out->CreateDataProperty(context, out_index, + Marker(isolate, "[MaxProperties ~]")) + .FromMaybe(false); } + result = out; + } else { + // Collect own enumerable property names (string-keyed). Integer-indexed + // keys are skipped, as they were never serialized. + v8::Local props; + if (obj->GetPropertyNames(context, v8::KeyCollectionMode::kOwnOnly, + static_cast( + v8::ONLY_ENUMERABLE | v8::SKIP_SYMBOLS), + v8::IndexFilter::kSkipIndices) + .ToLocal(&props)) { + auto out = + v8::Object::New(isolate, v8::Null(isolate), nullptr, nullptr, 0); + auto len = props->Length(); + if (len > kMaxStateBreadth) { + // Collecting the keys costs O(len), so the keys that are not visited + // are charged to the budget as well + auto skipped = len - kMaxStateBreadth; + state.remaining_nodes -= + skipped < state.remaining_nodes ? skipped : state.remaining_nodes; + } - auto props = maybe_props.ToLocalChecked(); - auto out = v8::Object::New(isolate); - auto len = props->Length(); - for (uint32_t i = 0; i < len; ++i) { - auto maybeKey = props->Get(context, i); - if (maybeKey.IsEmpty()) - continue; + for (uint32_t i = 0; i < len && !state.terminating; ++i) { + v8::Local key; + if (!props->Get(context, i).ToLocal(&key)) { + RecoverFromException(state); + continue; + } - auto key = maybeKey.ToLocalChecked(); - if (!key->IsString()) { - // Skip symbol and non-string keys to match JSON behavior - continue; - } + if (!key->IsString()) { + // Skip symbol and non-string keys to match JSON behavior + continue; + } - auto maybeVal = obj->Get(context, key); - if (maybeVal.IsEmpty()) - continue; + auto key_str = key.As(); + auto key_length = static_cast(key_str->Length()); + auto key_fits = key_length <= state.remaining_string_length; + state.remaining_string_length = + key_fits ? state.remaining_string_length - key_length : 0; + + if (!key_fits || i >= kMaxStateBreadth || !TakeNode(state)) { + // Cut the object here. The marker takes this key, or "..." if the + // key itself does not fit into the string budget. + out->CreateDataProperty(context, + key_fits ? key_str : Marker(isolate, "..."), + Marker(isolate, "[MaxProperties ~]")) + .FromMaybe(false); + break; + } - auto val = maybeVal.ToLocalChecked(); - auto sanitized = SanitizeForJSON(isolate, context, val, ancestors); - if (!sanitized->IsUndefined()) { - out->Set(context, key, sanitized).Check(); + v8::Local val; + if (!obj->Get(context, key_str).ToLocal(&val)) { + RecoverFromException(state); + continue; // drop properties that cannot be read + } + + auto sanitized = SanitizeForJSON(isolate, context, val, state); + if (!sanitized->IsUndefined()) { + out->CreateDataProperty(context, key_str, sanitized).FromMaybe(false); + } + // else: undefined -> drop property } - // else: undefined -> drop property + result = out; + } else { + RecoverFromException(state); // drop objects whose keys cannot be read } - - ancestors.pop_back(); - return out; } - // Fallback: return as-is (shouldn't hit here for other exotic types) - return value; + state.ancestors.pop_back(); + if (state.terminating) { + return v8::Undefined(isolate); + } + return result; } std::string JSONStringify(Isolate *isolate, Local value) { auto context = isolate->GetCurrentContext(); + // Exceptions thrown while reading the value (getters) are caught here, so + // they never reach the JavaScript running on this thread + v8::TryCatch try_catch(isolate); + // Sanitize the value first to avoid JSON failures (e.g., BigInt, cycles) - std::vector> ancestors; - auto sanitized = SanitizeForJSON(isolate, context, value, ancestors); - if (sanitized->IsUndefined()) { - // Nothing serializable - return ""; + SanitizeState state(try_catch); + auto sanitized = SanitizeForJSON(isolate, context, value, state); + + // Undefined: nothing serializable, or execution is terminating + std::string json; + v8::Local json_str; + if (!sanitized->IsUndefined() && + v8::JSON::Stringify(context, sanitized).ToLocal(&json_str)) { + v8::String::Utf8Value utf8(isolate, json_str); + json = *utf8 ? *utf8 : ""; } - auto maybe_json = v8::JSON::Stringify(context, sanitized); - if (maybe_json.IsEmpty()) { - return ""; - } - v8::String::Utf8Value utf8(isolate, maybe_json.ToLocalChecked()); - return *utf8 ? *utf8 : ""; + KeepTermination(isolate, try_catch); + return json; } // Function to get stack frames from a V8 stack trace @@ -352,7 +553,12 @@ static void ExecutionInterrupted(Isolate *isolate, void *data) { #if SUPPORTS_ASYNC_CONTEXT_FRAME if (args->store && args->store->has_value()) { + // Exceptions while reading the state (e.g. from getters or proxy traps on + // the stateLookup path) must not reach the interrupted JavaScript; the + // state is left empty or partial instead. Execution termination is kept. + TryCatch try_catch(isolate); state = GetThreadState(isolate, args->store->value()); + KeepTermination(isolate, try_catch); } #endif diff --git a/test/bounded-breadth.mjs b/test/bounded-breadth.mjs new file mode 100644 index 0000000..9f8577f --- /dev/null +++ b/test/bounded-breadth.mjs @@ -0,0 +1,13 @@ +// Breadth limit: arrays/objects with more than 1000 entries are cut. +import { captureWhileBlocked } from './bounded-harness.mjs'; + +const range = n => Array.from({ length: n }, (_, i) => i); + +const store = { + wideArray: range(1500), + exactArray: range(1000), + wideObject: Object.fromEntries(range(1500).map(i => [`k${i}`, i])), + exactObject: Object.fromEntries(range(1000).map(i => [`k${i}`, i])), +}; + +captureWhileBlocked({ store }); diff --git a/test/bounded-depth.mjs b/test/bounded-depth.mjs new file mode 100644 index 0000000..ee3ca94 --- /dev/null +++ b/test/bounded-depth.mjs @@ -0,0 +1,32 @@ +// Depth limit with Sentry's state shape: the state is looked up like +// eventLoopBlockIntegration does (stateLookup ['_currentContext', scopes symbol]) +// and breadcrumb data sits at isolationScope._breadcrumbs[i].data. +import { captureWhileBlocked } from './bounded-harness.mjs'; + +const SCOPES = Symbol.for('sentry_scopes'); + +function nest(depth, wrap, leaf) { + let value = leaf; + for (let i = 0; i < depth; i++) { + value = wrap(value); + } + return value; +} + +const scopes = { + isolationScope: { + _breadcrumbs: [ + { + category: 'console', + data: { arguments: [{ a: { b: { c: { d: { e: 'deep' } } } } }] }, + }, + ], + }, + objectChain: nest(15, child => ({ child }), 'leaf'), + arrayChain: nest(15, child => [child], 'leaf'), +}; + +captureWhileBlocked({ + store: { _currentContext: new Map([[SCOPES, scopes]]) }, + stateLookup: ['_currentContext', SCOPES], +}); diff --git a/test/bounded-getter-budget.mjs b/test/bounded-getter-budget.mjs new file mode 100644 index 0000000..3f23e6b --- /dev/null +++ b/test/bounded-getter-budget.mjs @@ -0,0 +1,24 @@ +// The read budget also bounds how often getters run: 1000 getters that each +// return the same object with 1000 getters would run 1,001,000 getters. +import { captureWhileBlocked } from './bounded-harness.mjs'; + +let getterReads = 0; + +function withGetters(value) { + const obj = {}; + for (let i = 0; i < 1000; i++) { + Object.defineProperty(obj, `k${i}`, { + enumerable: true, + get() { + getterReads++; + return value(i); + }, + }); + } + return obj; +} + +const inner = withGetters(i => i); +const outer = withGetters(() => inner); + +captureWhileBlocked({ store: { outer }, report: () => ({ getterReads }) }); diff --git a/test/bounded-harness.mjs b/test/bounded-harness.mjs new file mode 100644 index 0000000..dc4ce4d --- /dev/null +++ b/test/bounded-harness.mjs @@ -0,0 +1,93 @@ +// Shared driver for the bounded state serialization fixtures. +// +// The main thread blocks in a JavaScript busy loop inside an AsyncLocalStorage +// store while a watchdog worker calls captureStackTrace(), so the state is +// serialized while the main thread's JavaScript is interrupted. The fixture +// prints a single JSON line with the capture result, whether the interrupted +// JavaScript saw an exception and how much the peak RSS grew. +// +// The busy loop gives up after MAX_BLOCK_MS, but cannot interrupt a stuck +// native serialization; e2e.test.mjs kills fixtures that run too long. +import { AsyncLocalStorage } from 'node:async_hooks'; +import { Worker } from 'node:worker_threads'; +import { registerThread } from '@sentry/node-native-stacktrace'; + +export const MAX_BLOCK_MS = 10000; + +/** + * Busy-waits inside the current AsyncLocalStorage store until flags[1] is set. + * Sets flags[0] first, which starts the capture in bounded-watchdog.mjs. + * Returns true if MAX_BLOCK_MS passed without the capture finishing. + */ +export function blockedInsideState(flags) { + Atomics.store(flags, 0, 1); + Atomics.notify(flags, 0); + const deadline = Date.now() + MAX_BLOCK_MS; + while (Atomics.load(flags, 1) === 0) { + if (Date.now() > deadline) { + return true; + } + } + return false; +} + +/** + * Starts bounded-watchdog.mjs, which captures all registered threads once + * flags[0] is set and then sets flags[1]. + */ +export function startWatchdog(flags) { + return new Worker(new URL('./bounded-watchdog.mjs', import.meta.url), { workerData: { flags } }); +} + +/** Prints the fixture result as a single JSON line. */ +export function printResult(result) { + // ASCII only: runTest() decodes stdout chunk by chunk, which would break + // multi-byte UTF-8 sequences split across chunks + const json = JSON.stringify(result); + console.log(json.replace(/[\u007f-\uffff]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)); +} + +function describeError(e) { + return `${e?.name}: ${e?.message}`; +} + +/** + * @param {object} options + * @param {unknown} [options.store] AsyncLocalStorage store the main thread blocks in + * @param {Array} [options.stateLookup] passed to registerThread + * @param {() => void} [options.beforeBlock] runs right before blocking, errors are reported as setupError + * @param {() => void} [options.afterBlock] runs right after blocking, before anything is printed + * @param {() => object} [options.report] extra fields for the printed result + */ +export function captureWhileBlocked({ store, stateLookup, beforeBlock, afterBlock, report }) { + const asyncLocalStorage = new AsyncLocalStorage(); + registerThread(stateLookup ? { asyncLocalStorage, stateLookup } : { asyncLocalStorage }); + + // flags[0]: main thread is blocked inside the store, flags[1]: capture is done + const flags = new Int32Array(new SharedArrayBuffer(8)); + const watchdog = startWatchdog(flags); + const maxRssBeforeKb = process.resourceUsage().maxRSS; + + let setupError = null; + try { + beforeBlock?.(); + } catch (e) { + setupError = describeError(e); + } + + let appError = null; + let timedOut = false; + asyncLocalStorage.run(store, () => { + try { + timedOut = blockedInsideState(flags); + } catch (e) { + appError = describeError(e); + } + }); + afterBlock?.(); + + watchdog.once('message', result => { + const maxRssGrowthMb = (process.resourceUsage().maxRSS - maxRssBeforeKb) / 1024; + printResult({ ...result, appError, setupError, timedOut, maxRssGrowthMb, ...report?.() }); + }); +} diff --git a/test/bounded-key-after.mjs b/test/bounded-key-after.mjs new file mode 100644 index 0000000..4b321c0 --- /dev/null +++ b/test/bounded-key-after.mjs @@ -0,0 +1,8 @@ +// String budget used up exactly by retained keys and values: "ok" (2) + "a" (1) +// + the value of "a" (1048573) = 1 MiB. The value of "a" is kept whole, and +// the next key ("b") no longer fits, so the object ends with a marker. +import { captureWhileBlocked } from './bounded-harness.mjs'; + +const store = { ok: 1, a: 'y'.repeat(1024 * 1024 - 3), b: 2, c: 3 }; + +captureWhileBlocked({ store }); diff --git a/test/bounded-key-first.mjs b/test/bounded-key-first.mjs new file mode 100644 index 0000000..a9035e9 --- /dev/null +++ b/test/bounded-key-first.mjs @@ -0,0 +1,11 @@ +// String budget applied to keys: the first key of an object does not fit into +// the 1 MiB string budget. The object is cut with a visible marker, and every +// later key (here "later" of the root object) is cut the same way. +import { captureWhileBlocked } from './bounded-harness.mjs'; + +const store = { + first: { ['x'.repeat(1024 * 1024 + 1)]: 1, after: 2 }, + later: 'x', +}; + +captureWhileBlocked({ store }); diff --git a/test/bounded-node-budget.mjs b/test/bounded-node-budget.mjs new file mode 100644 index 0000000..6a8f274 --- /dev/null +++ b/test/bounded-node-budget.mjs @@ -0,0 +1,11 @@ +// Shallow but wide sharing: three levels of 1000 references to the next level +// (10^9 paths). Neither the depth nor the breadth limit bounds this graph, only +// the total number of visited values does. +import { captureWhileBlocked } from './bounded-harness.mjs'; + +let graph = { leaf: 1 }; +for (let i = 0; i < 3; i++) { + graph = new Array(1000).fill(graph); +} + +captureWhileBlocked({ store: { graph } }); diff --git a/test/bounded-poll-state.mjs b/test/bounded-poll-state.mjs new file mode 100644 index 0000000..b32003e --- /dev/null +++ b/test/bounded-poll-state.mjs @@ -0,0 +1,31 @@ +// threadPoll state goes through the same serializer: a shared-reference graph +// of depth 24 and a throwing getter must neither stall nor throw into the +// caller of threadPoll. +import { threadPoll } from '@sentry/node-native-stacktrace'; +import { captureWhileBlocked } from './bounded-harness.mjs'; + +let graph = { leaf: 'x' }; +for (let i = 0; i < 24; i++) { + graph = { a: graph, b: graph }; +} + +let throwingReads = 0; +const pollState = { ok: 1, graph }; +Object.defineProperty(pollState, 'boom', { + enumerable: true, + get() { + throwingReads++; + throw new Error('poll getter boom'); + }, +}); + +let threadPollMs; +captureWhileBlocked({ + store: {}, + beforeBlock: () => { + const start = performance.now(); + threadPoll(true, pollState); + threadPollMs = performance.now() - start; + }, + report: () => ({ throwingReads, threadPollMs }), +}); diff --git a/test/bounded-proxy.mjs b/test/bounded-proxy.mjs new file mode 100644 index 0000000..d4ba1b1 --- /dev/null +++ b/test/bounded-proxy.mjs @@ -0,0 +1,28 @@ +// Proxies in the state become "[Proxy]" without running any trap. Every +// operation on a proxy looks up its trap on the handler, so a handler that is +// itself a proxy records each trap V8 would run. +import { captureWhileBlocked } from './bounded-harness.mjs'; + +const trapLookups = []; +const recordingHandler = new Proxy( + {}, + { + get(_, trap) { + trapLookups.push(String(trap)); + return undefined; // no trap: the operation goes to the target + }, + }, +); + +const revocable = Proxy.revocable({ revoked: true }, {}); +revocable.revoke(); + +const store = { + before: 1, + plain: new Proxy({ secret: 'object target' }, recordingHandler), + list: [new Proxy(['array target'], recordingHandler), 2], + revoked: revocable.proxy, + after: 'kept', +}; + +captureWhileBlocked({ store, report: () => ({ trapLookups }) }); diff --git a/test/bounded-semantics.mjs b/test/bounded-semantics.mjs new file mode 100644 index 0000000..22892a7 --- /dev/null +++ b/test/bounded-semantics.mjs @@ -0,0 +1,24 @@ +// AC5: small states keep their JSON semantics: own enumerable string-keyed +// properties only, unsupported values dropped, cycles dropped, repeated +// (non-cyclic) references kept. Integer-like keys of objects stay left out, as +// before (see README "State serialization"). +import { captureWhileBlocked } from './bounded-harness.mjs'; + +const state = Object.create({ inherited: 'from prototype' }); +state.traceId = 'trace-5'; +state.count = 3; +state.flag = true; +state.nothing = null; +state.big = 10n; +state.fn = () => {}; +state.undef = undefined; +state[Symbol('symbolKey')] = 'symbol keyed'; +Object.defineProperty(state, 'hidden', { value: 'not enumerable', enumerable: false }); +state[7] = 'integer-like key'; +state.self = state; +state.list = [1, 'two', undefined, () => {}, Symbol('symbolValue'), { deep: { deeper: true } }]; +state.list.push(state.list); +state.shared = { x: 1 }; +state.alsoShared = state.shared; + +captureWhileBlocked({ store: { state }, stateLookup: ['state'] }); diff --git a/test/bounded-shared-graph.mjs b/test/bounded-shared-graph.mjs new file mode 100644 index 0000000..7c5af36 --- /dev/null +++ b/test/bounded-shared-graph.mjs @@ -0,0 +1,10 @@ +// AC2: a graph of depth 24 where every level references the next level twice. +// Copying shared references once per path would produce 2^24 leaves. +import { captureWhileBlocked } from './bounded-harness.mjs'; + +let graph = { leaf: 'x' }; +for (let i = 0; i < 24; i++) { + graph = { a: graph, b: graph }; +} + +captureWhileBlocked({ store: graph }); diff --git a/test/bounded-strings.mjs b/test/bounded-strings.mjs new file mode 100644 index 0000000..8765c11 --- /dev/null +++ b/test/bounded-strings.mjs @@ -0,0 +1,12 @@ +// String budget: one large string referenced many times. Each reference would +// otherwise be copied into the output again. +// +// The string is made of surrogate pairs and the key "items" has an odd length, +// so the remaining budget (1 MiB - 5 - 3 * 307200 = 126971 code units) ends in +// the middle of a pair and the cut must not leave a lone surrogate behind. +import { captureWhileBlocked } from './bounded-harness.mjs'; + +const big = '\u{1F600}'.repeat(150 * 1024); // 307200 UTF-16 code units +const items = new Array(8).fill(big); + +captureWhileBlocked({ store: { state: { items } }, stateLookup: ['state'], report: () => ({ bigLength: big.length }) }); diff --git a/test/bounded-terminate-target.mjs b/test/bounded-terminate-target.mjs new file mode 100644 index 0000000..44fb16a --- /dev/null +++ b/test/bounded-terminate-target.mjs @@ -0,0 +1,46 @@ +// Worker for bounded-terminate.mjs: blocks inside an AsyncLocalStorage store +// whose getter keeps running until the worker is terminated. The blocked loop +// does not end when the capture is done, so only a termination that survives +// the capture stops this worker early. +import { AsyncLocalStorage } from 'node:async_hooks'; +import { workerData } from 'node:worker_threads'; +import { registerThread } from '@sentry/node-native-stacktrace'; + +// The getter and the blocked loop give up after this long; termination must +// stop them much earlier +const GETTER_MAX_MS = 6000; +const BLOCK_MAX_MS = 8000; + +const { flags, sync } = workerData; + +const asyncLocalStorage = new AsyncLocalStorage(); +registerThread({ asyncLocalStorage }, 'target'); + +const store = { before: 1 }; +Object.defineProperty(store, 'slow', { + enumerable: true, + get() { + Atomics.store(sync, 0, 1); // getter started + Atomics.notify(sync, 0); + const deadline = Date.now() + GETTER_MAX_MS; + while (Date.now() < deadline) { + // busy, like a slow application getter + } + Atomics.store(sync, 1, 1); // getter ran to its deadline + return 'slow'; + }, +}); + +function blockedUntilTerminated() { + Atomics.store(flags, 0, 1); // starts the capture in bounded-watchdog.mjs + Atomics.notify(flags, 0); + const deadline = Date.now() + BLOCK_MAX_MS; + while (Date.now() < deadline) { + // busy + } +} + +asyncLocalStorage.run(store, () => { + blockedUntilTerminated(); + Atomics.store(sync, 2, 1); // the blocked loop returned +}); diff --git a/test/bounded-terminate.mjs b/test/bounded-terminate.mjs new file mode 100644 index 0000000..ea2dbd9 --- /dev/null +++ b/test/bounded-terminate.mjs @@ -0,0 +1,59 @@ +// worker.terminate() while the worker's state is serialized: a state getter +// is running inside the capture interrupt when terminate() is called. The +// serialization must not swallow the termination, so the FIRST terminate() +// call stops the worker promptly and the capture returns. +import { Worker } from 'node:worker_threads'; +import { performance } from 'node:perf_hooks'; +import { printResult, startWatchdog } from './bounded-harness.mjs'; + +// Each wait is short enough that all of them end before the parent deadline +const WAIT_MS = 3000; + +// flags[0]: target is blocked (starts the capture in bounded-watchdog.mjs). +// sync[0]: getter started, sync[1]: getter ran to its deadline, sync[2]: +// blocked loop returned +const flags = new Int32Array(new SharedArrayBuffer(8)); +const sync = new Int32Array(new SharedArrayBuffer(12)); + +function within(ms, promise) { + let timer; + const timeout = new Promise(resolve => { + timer = setTimeout(() => resolve({ timedOut: true }), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +const target = new Worker(new URL('./bounded-terminate-target.mjs', import.meta.url), { + workerData: { flags, sync }, +}); +const targetErrors = []; +target.on('error', e => targetErrors.push(`${e?.name}: ${e?.message}`)); + +const watchdog = startWatchdog(flags); +const capture = new Promise(resolve => watchdog.once('message', resolve)); + +const getterStarted = await within(WAIT_MS, Atomics.waitAsync(sync, 0, 0).value); + +let terminateCalls = 0; +let terminate = { timedOut: true }; +const start = performance.now(); +if (!getterStarted.timedOut) { + terminateCalls++; + terminate = await within(WAIT_MS, target.terminate().then(exitCode => ({ exitCode }))); +} +const terminateMs = performance.now() - start; + +const captureResult = await within(WAIT_MS, capture); + +printResult({ + getterStarted: !getterStarted.timedOut, + terminateCalls, + terminateResolved: !terminate.timedOut, + terminateMs, + exitCode: terminate.exitCode, + getterRanToDeadline: Atomics.load(sync, 1) === 1, + blockedLoopReturned: Atomics.load(sync, 2) === 1, + targetErrors, + capture: captureResult, +}); +process.exit(0); diff --git a/test/bounded-throwing-lookup.mjs b/test/bounded-throwing-lookup.mjs new file mode 100644 index 0000000..a056285 --- /dev/null +++ b/test/bounded-throwing-lookup.mjs @@ -0,0 +1,15 @@ +// AC4: the stateLookup path itself throws while the state is located. +import { captureWhileBlocked } from './bounded-harness.mjs'; + +let throwingReads = 0; + +const store = {}; +Object.defineProperty(store, 'scopes', { + enumerable: true, + get() { + throwingReads++; + throw new Error('lookup boom'); + }, +}); + +captureWhileBlocked({ store, stateLookup: ['scopes'], report: () => ({ throwingReads }) }); diff --git a/test/bounded-throwing.mjs b/test/bounded-throwing.mjs new file mode 100644 index 0000000..8ccc17d --- /dev/null +++ b/test/bounded-throwing.mjs @@ -0,0 +1,27 @@ +// AC4: getters in the state throw while it is serialized. The exceptions must +// not reach the interrupted JavaScript and the readable properties must be kept. +import { captureWhileBlocked } from './bounded-harness.mjs'; + +let throwingReads = 0; + +function throwing(value) { + return { + enumerable: true, + get() { + throwingReads++; + throw value; + }, + }; +} + +const store = { ok: 1 }; +Object.defineProperty(store, 'boom', throwing(new Error('getter boom'))); +Object.defineProperty(store, 'boomValue', throwing('not an error')); +store.list = [1, 2, 3]; +Object.defineProperty(store.list, 1, throwing(new Error('element boom'))); +store.nested = { fine: 'yes' }; +Object.defineProperty(store.nested, 'deep', throwing(new TypeError('nested boom'))); +store.nested.alsoFine = 2; +store.after = 'kept'; + +captureWhileBlocked({ store, report: () => ({ throwingReads }) }); diff --git a/test/bounded-timers.mjs b/test/bounded-timers.mjs new file mode 100644 index 0000000..c272919 --- /dev/null +++ b/test/bounded-timers.mjs @@ -0,0 +1,15 @@ +// AC3: a Node Timeout in the state while 12,000 other timers with the same +// duration exist. Timers with the same duration form one linked list +// (_idlePrev/_idleNext), so the state references a 12,000 element chain. +import { captureWhileBlocked } from './bounded-harness.mjs'; + +const noop = () => {}; +const timers = []; +for (let i = 0; i < 12000; i++) { + // unref() keeps the timer in its list but lets the process exit + timers.push(setTimeout(noop, 60000).unref()); +} + +const store = { profiler: { _chunkData: { timer: setTimeout(noop, 60000).unref() } } }; + +captureWhileBlocked({ store, report: () => ({ timers: timers.length }) }); diff --git a/test/bounded-tojson-array.mjs b/test/bounded-tojson-array.mjs new file mode 100644 index 0000000..24c0cdb --- /dev/null +++ b/test/bounded-tojson-array.mjs @@ -0,0 +1,18 @@ +// Inherited toJSON hooks cannot change the serialized state: a throwing +// Object.prototype.toJSON and an oversized Array.prototype.toJSON. +import { captureWhileBlocked } from './bounded-harness.mjs'; +import { installToJSONHooks, toJSONState } from './bounded-tojson.mjs'; + +const hooks = installToJSONHooks({ + object: () => { + throw new Error('Object.prototype.toJSON boom'); + }, + array: () => 'x'.repeat(4 * 1024 * 1024), +}); + +captureWhileBlocked({ + store: toJSONState(), + beforeBlock: hooks.install, + afterBlock: hooks.restore, + report: () => ({ toJSONCalls: hooks.calls() }), +}); diff --git a/test/bounded-tojson-object.mjs b/test/bounded-tojson-object.mjs new file mode 100644 index 0000000..b3f028e --- /dev/null +++ b/test/bounded-tojson-object.mjs @@ -0,0 +1,18 @@ +// Inherited toJSON hooks cannot change the serialized state: an oversized +// Object.prototype.toJSON and a throwing Array.prototype.toJSON. +import { captureWhileBlocked } from './bounded-harness.mjs'; +import { installToJSONHooks, toJSONState } from './bounded-tojson.mjs'; + +const hooks = installToJSONHooks({ + object: () => 'x'.repeat(4 * 1024 * 1024), + array: () => { + throw new Error('Array.prototype.toJSON boom'); + }, +}); + +captureWhileBlocked({ + store: toJSONState(), + beforeBlock: hooks.install, + afterBlock: hooks.restore, + report: () => ({ toJSONCalls: hooks.calls() }), +}); diff --git a/test/bounded-tojson.mjs b/test/bounded-tojson.mjs new file mode 100644 index 0000000..4c3e7fb --- /dev/null +++ b/test/bounded-tojson.mjs @@ -0,0 +1,31 @@ +// Helpers for the bounded-tojson-*.mjs fixtures + +/** The state used by the toJSON fixtures, see 'inherited toJSON hooks' in e2e.test.mjs */ +export function toJSONState() { + return { text: 't', obj: { a: 1 }, list: [1, { b: 2 }, [3]] }; +} + +/** + * Prepares toJSON hooks on Object.prototype and Array.prototype. They are only + * installed while the main thread is blocked, so nothing else (e.g. printing + * the result) sees them. + */ +export function installToJSONHooks({ object, array }) { + let calls = 0; + const counted = hook => function toJSON() { + calls++; + return hook(); + }; + + return { + install() { + Object.defineProperty(Object.prototype, 'toJSON', { value: counted(object), configurable: true, writable: true }); + Object.defineProperty(Array.prototype, 'toJSON', { value: counted(array), configurable: true, writable: true }); + }, + restore() { + delete Object.prototype.toJSON; + delete Array.prototype.toJSON; + }, + calls: () => calls, + }; +} diff --git a/test/bounded-watchdog.mjs b/test/bounded-watchdog.mjs new file mode 100644 index 0000000..9101313 --- /dev/null +++ b/test/bounded-watchdog.mjs @@ -0,0 +1,34 @@ +// Watchdog worker for bounded-harness.mjs: waits until the main thread is +// blocked inside its AsyncLocalStorage store, captures it and releases it. +import { performance } from 'node:perf_hooks'; +import { parentPort, workerData } from 'node:worker_threads'; +import { captureStackTrace } from '@sentry/node-native-stacktrace'; + +const { flags } = workerData; + +function jsonLength(value) { + return value === undefined ? 0 : JSON.stringify(value).length; +} + +Atomics.wait(flags, 0, 0, 15000); + +let result; +try { + const start = performance.now(); + const trace = captureStackTrace(); + const captureMs = performance.now() - start; + // The main thread registers without a name, so it is keyed by its threadId + const main = trace['0']; + result = { + captureMs, + stateJsonLength: jsonLength(main?.asyncState), + pollStateJsonLength: jsonLength(main?.pollState), + trace, + }; +} catch (e) { + result = { captureError: `${e?.name}: ${e?.message}` }; +} finally { + Atomics.store(flags, 1, 1); +} + +parentPort.postMessage(result); diff --git a/test/e2e.test.mjs b/test/e2e.test.mjs index e11d0df..b01d98a 100644 --- a/test/e2e.test.mjs +++ b/test/e2e.test.mjs @@ -10,8 +10,13 @@ const NODE_MAJOR_VERSION = parseInt(process.versions.node.split('.')[0], 10); const timeout = process.env.CI && process.platform === 'darwin' ? 60000 : 20000; async function runTest(...paths) { + return runFile(join(...paths)); +} + +// Runs a node script and collects its output. With deadlineMs, a script that +// is still running after deadlineMs is killed and the run fails. +async function runFile(file, deadlineMs) { console.time('Test Run'); - const file = join(...paths); const args = NODE_MAJOR_VERSION === 22 ? ['--experimental-async-context-frame', file] : [file]; return new Promise((resolve, reject) => { @@ -27,9 +32,21 @@ async function runTest(...paths) { stderrBuf += chunk.toString(); }); - child.on('error', err => reject(err)); + let killedAtDeadline = false; + const deadline = deadlineMs === undefined ? undefined : setTimeout(() => { + killedAtDeadline = true; + child.kill('SIGKILL'); + }, deadlineMs); + + child.on('error', err => { + clearTimeout(deadline); + reject(err); + }); + // 'close' fires once the process has exited (and been reaped) and its + // output streams are closed, also after it was killed at the deadline child.on('close', code => { + clearTimeout(deadline); const stdout = stdoutBuf .split('\n') .map(line => line.trim()) @@ -57,6 +74,14 @@ async function runTest(...paths) { console.log('stderr:', stderr); } + if (killedAtDeadline) { + reject(new Error( + `Fixture timeout: ${file} did not exit within ${deadlineMs} ms and was killed (SIGKILL). ` + + `stdout: ${JSON.stringify(stdout).slice(0, 2000)} stderr: ${JSON.stringify(stderr).slice(0, 2000)}`, + )); + return; + } + resolve({ status: code, stdout, trace }); }); }); @@ -231,3 +256,318 @@ describe('e2e Tests', { timeout }, () => { expect(result.stdout).toContain('complete'); }); }); + +// Bounded fixtures are killed after this long, before vitest gives up on the +// test (vitest does not stop a child process when a test times out) +const FIXTURE_DEADLINE_MS = timeout - 5000; + +function runBounded(fixture) { + return runFile(join(__dirname, fixture), FIXTURE_DEADLINE_MS); +} + +// Loose limits: the specified targets are < 100 ms and < 50 MB, the unbounded +// serializer needed > 1 s and > 800 MB for these inputs. The RSS growth also +// includes starting the watchdog worker. +const MAX_CAPTURE_MS = 2000; +const MAX_RSS_GROWTH_MB = 100; + +// Asserts that the main thread was captured while blocked inside its +// AsyncLocalStorage store and that the interrupted JavaScript saw no exception +function expectCleanCapture(result) { + expect(result.status).toEqual(0); + + const report = result.trace; + expect(report).toEqual(expect.objectContaining({ appError: null, setupError: null, timedOut: false })); + expect(report.captureError).toBeUndefined(); + expect(report.captureMs).toBeLessThan(MAX_CAPTURE_MS); + expect(report.maxRssGrowthMb).toBeLessThan(MAX_RSS_GROWTH_MB); + + const thread = report.trace['0']; + expect(thread.frames).toEqual(expect.arrayContaining([ + { + function: 'blockedInsideState', + filename: expect.stringMatching(/bounded-harness.mjs$/), + lineno: expect.any(Number), + colno: expect.any(Number), + }, + ])); + + return { report, thread }; +} + +function follow(value, key, hops) { + for (let i = 0; i < hops; i++) { + value = value[key]; + } + return value; +} + +// Number of objects followed along `key` before reaching a non-object +function chainLength(value, key) { + let length = 0; + while (typeof value === 'object' && value !== null) { + value = value[key]; + length++; + } + return { length, end: value }; +} + +function nest(depth, wrap, leaf) { + let value = leaf; + for (let i = 0; i < depth; i++) { + value = wrap(value); + } + return value; +} + +const range = n => Array.from({ length: n }, (_, i) => i); + +const MAX_PROPERTIES = '[MaxProperties ~]'; + +describe('bounded state serialization', { timeout }, () => { + const asyncStateTest = test.skipIf(NODE_MAJOR_VERSION < 22); + + asyncStateTest('shared-reference graph of depth 24', async () => { + const { report, thread } = expectCleanCapture(await runBounded('bounded-shared-graph.mjs')); + + expect(report.stateJsonLength).toBeGreaterThan(0); + expect(report.stateJsonLength).toBeLessThan(64 * 1024); + // Nesting is cut at depth 10, long before the leaf + expect(follow(thread.asyncState, 'a', 9)).toEqual({ a: '[Object]', b: '[Object]' }); + expect(follow(thread.asyncState, 'b', 9)).toEqual({ a: '[Object]', b: '[Object]' }); + expect(JSON.stringify(thread.asyncState)).not.toContain('leaf'); + }); + + asyncStateTest('wide shared references are cut by the read budget', async () => { + const { report, thread } = expectCleanCapture(await runBounded('bounded-node-budget.mjs')); + + const json = JSON.stringify(thread.asyncState); + // Each copied leaf costs two reads (the array element and its property), + // so at most 10,000 of the 10^9 leaves fit into 20,000 reads + const copiedLeaves = json.split('{"leaf":1}').length - 1; + expect(copiedLeaves).toBeGreaterThan(9900); + expect(copiedLeaves).toBeLessThanOrEqual(10000); + expect(report.stateJsonLength).toBeLessThan(512 * 1024); + + // Once the budget is used up nothing more is read: every array that was + // being copied ends with the marker + const { graph } = thread.asyncState; + expect(graph).toEqual([expect.any(Array), MAX_PROPERTIES]); + const [first] = graph; + expect(first.at(-1)).toEqual(MAX_PROPERTIES); + expect(first.length).toBeLessThan(1000); + const copied = first.slice(0, -1); + expect(copied.slice(0, -1).every(level => level.length === 1000)).toBe(true); + expect(copied.at(-1).at(-1)).toEqual(MAX_PROPERTIES); + }); + + asyncStateTest('read budget stops further getter calls', async () => { + const { report, thread } = expectCleanCapture(await runBounded('bounded-getter-budget.mjs')); + + // 1,001,000 getters without the budget, at most 20,000 reads with it + expect(report.getterReads).toBeGreaterThan(10000); + expect(report.getterReads).toBeLessThanOrEqual(20000); + + const outer = Object.entries(thread.asyncState.outer); + expect(outer.length).toBeLessThan(1000); + expect(outer.at(-1)[1]).toEqual(MAX_PROPERTIES); + expect(outer[0]).toEqual(['k0', Object.fromEntries(range(1000).map(i => [`k${i}`, i]))]); + expect(report.stateJsonLength).toBeLessThan(512 * 1024); + }); + + asyncStateTest('Node Timeout in a list of 12,000 timers', async () => { + const { report, thread } = expectCleanCapture(await runBounded('bounded-timers.mjs')); + + expect(report.timers).toEqual(12000); + expect(report.stateJsonLength).toBeLessThan(64 * 1024); + + const timer = thread.asyncState.profiler._chunkData.timer; + expect(timer._idleTimeout).toEqual(60000); + // Both directions of the timer list end in a depth marker after a few links + for (const key of ['_idlePrev', '_idleNext']) { + const chain = chainLength(timer[key], key); + expect(chain.end).toEqual('[Object]'); + expect(chain.length).toBeGreaterThan(0); + expect(chain.length).toBeLessThan(10); + } + }); + + asyncStateTest('exceptions while reading the state do not reach the application', async () => { + const { report, thread } = expectCleanCapture(await runBounded('bounded-throwing.mjs')); + + // Every throwing getter ran during the capture + expect(report.throwingReads).toEqual(4); + expect(thread.asyncState).toEqual({ + ok: 1, + list: [1, 3], + nested: { fine: 'yes', alsoFine: 2 }, + after: 'kept', + }); + }); + + asyncStateTest('exceptions while looking up the state do not reach the application', async () => { + const { report, thread } = expectCleanCapture(await runBounded('bounded-throwing-lookup.mjs')); + + expect(report.throwingReads).toEqual(1); + expect(thread.asyncState).toBeUndefined(); + }); + + asyncStateTest('proxies become "[Proxy]" without running traps', async () => { + const { report, thread } = expectCleanCapture(await runBounded('bounded-proxy.mjs')); + + expect(report.trapLookups).toEqual([]); + expect(thread.asyncState).toEqual({ + before: 1, + plain: '[Proxy]', + list: ['[Proxy]', 2], + revoked: '[Proxy]', + after: 'kept', + }); + }); + + for (const fixture of ['bounded-tojson-object.mjs', 'bounded-tojson-array.mjs']) { + asyncStateTest(`inherited toJSON hooks cannot change the state (${fixture})`, async () => { + const { report, thread } = expectCleanCapture(await runBounded(fixture)); + + expect(report.toJSONCalls).toEqual(0); + expect(thread.asyncState).toEqual({ text: 't', obj: { a: 1 }, list: [1, { b: 2 }, [3]] }); + expect(report.stateJsonLength).toBeLessThan(1024); + }); + } + + asyncStateTest('worker.terminate() during a state getter stops the worker on the first call', async () => { + const result = await runBounded('bounded-terminate.mjs'); + expect(result.status).toEqual(0); + + const report = result.trace; + expect(report).toEqual(expect.objectContaining({ + getterStarted: true, + terminateCalls: 1, + terminateResolved: true, + // Termination stopped the getter and the worker's own code + getterRanToDeadline: false, + blockedLoopReturned: false, + targetErrors: [], + })); + // The getter would keep running for 6 s, the blocked loop for 8 s + expect(report.terminateMs).toBeLessThan(MAX_CAPTURE_MS); + + // The capture returns the frames, without state from the stopped walk + const capture = report.capture; + expect(capture).toBeDefined(); + expect(capture.captureError).toBeUndefined(); + expect(capture.captureMs).toBeLessThan(MAX_CAPTURE_MS); + expect(capture.trace.target.frames).toEqual(expect.arrayContaining([ + expect.objectContaining({ + function: 'blockedUntilTerminated', + filename: expect.stringMatching(/bounded-terminate-target.mjs$/), + }), + ])); + expect(capture.trace.target.asyncState).toBeUndefined(); + }); + + asyncStateTest('large string referenced many times is cut by the string budget', async () => { + const { report, thread } = expectCleanCapture(await runBounded('bounded-strings.mjs')); + + const big = '\u{1F600}'.repeat(150 * 1024); + expect(report.bigLength).toEqual(big.length); + expect(report.stateJsonLength).toBeLessThanOrEqual(1024 * 1024 + 1024); + + const items = thread.asyncState.items; + expect(items).toHaveLength(8); + // Plain boolean checks: diffs of these strings would be megabytes long + expect(items.slice(0, 3).map(item => item === big)).toEqual([true, true, true]); + + const truncated = items[3]; + expect(truncated.endsWith('...')).toBe(true); + const prefix = truncated.slice(0, -3); + expect(prefix.length).toBeGreaterThan(0); + expect(prefix.length).toBeLessThan(big.length); + expect(big.startsWith(prefix)).toBe(true); + // The cut does not leave half of a surrogate pair behind + expect(/[\uD800-\uDBFF]$/.test(prefix)).toBe(false); + expect(3 * big.length + prefix.length).toBeLessThanOrEqual(1024 * 1024); + + expect(items.slice(4)).toEqual(['[String]', '[String]', '[String]', '[String]']); + }); + + asyncStateTest('a first key larger than the string budget cuts the object visibly', async () => { + const { report, thread } = expectCleanCapture(await runBounded('bounded-key-first.mjs')); + + // The oversized key and everything after it is replaced by the marker, + // also in the root object, whose next key no longer fits either + expect(thread.asyncState).toEqual({ first: { '...': MAX_PROPERTIES }, '...': MAX_PROPERTIES }); + expect(report.stateJsonLength).toBeLessThan(1024); + }); + + asyncStateTest('keys after the string budget is used up cut the object visibly', async () => { + const { report, thread } = expectCleanCapture(await runBounded('bounded-key-after.mjs')); + + const state = thread.asyncState; + expect(Object.keys(state)).toEqual(['ok', 'a', '...']); + expect(state.ok).toEqual(1); + // Plain boolean check: a diff of this string would be a megabyte long + expect(state.a === 'y'.repeat(1024 * 1024 - 3)).toBe(true); + expect(state['...']).toEqual(MAX_PROPERTIES); + expect(report.stateJsonLength).toBeLessThanOrEqual(1024 * 1024 + 1024); + }); + + asyncStateTest('arrays and objects with more than 1000 entries are cut', async () => { + const { thread } = expectCleanCapture(await runBounded('bounded-breadth.mjs')); + + const state = thread.asyncState; + expect(state.wideArray).toEqual([...range(1000), MAX_PROPERTIES]); + expect(state.exactArray).toEqual(range(1000)); + + const wideEntries = Object.entries(state.wideObject); + expect(wideEntries).toHaveLength(1001); + expect(wideEntries.slice(0, 1000)).toEqual(range(1000).map(i => [`k${i}`, i])); + expect(wideEntries[1000][1]).toEqual(MAX_PROPERTIES); + expect(state.exactObject).toEqual(Object.fromEntries(range(1000).map(i => [`k${i}`, i]))); + }); + + asyncStateTest('nesting is cut at depth 10 and keeps Sentry breadcrumb data', async () => { + const { thread } = expectCleanCapture(await runBounded('bounded-depth.mjs')); + + expect(thread.asyncState).toEqual({ + isolationScope: { + _breadcrumbs: [ + { + category: 'console', + // At least three levels below `data` are kept + data: { arguments: [{ a: { b: { c: { d: '[Object]' } } } }] }, + }, + ], + }, + objectChain: nest(9, child => ({ child }), '[Object]'), + arrayChain: nest(9, child => [child], '[Array]'), + }); + }); + + asyncStateTest('small states keep JSON semantics', async () => { + const { thread } = expectCleanCapture(await runBounded('bounded-semantics.mjs')); + + expect(thread.asyncState).toEqual({ + traceId: 'trace-5', + count: 3, + flag: true, + nothing: null, + list: [1, 'two', { deep: { deeper: true } }], + shared: { x: 1 }, + alsoShared: { x: 1 }, + }); + }); + + test('threadPoll state is bounded and exceptions do not reach the caller', async () => { + const { report, thread } = expectCleanCapture(await runBounded('bounded-poll-state.mjs')); + + expect(report.throwingReads).toEqual(1); + expect(report.threadPollMs).toBeLessThan(MAX_CAPTURE_MS); + expect(report.pollStateJsonLength).toBeLessThan(64 * 1024); + + const pollState = thread.pollState; + expect(pollState.ok).toEqual(1); + expect(pollState).not.toHaveProperty('boom'); + expect(follow(pollState.graph, 'a', 8)).toEqual({ a: '[Object]', b: '[Object]' }); + }); +});