From 4f6224cdf3a7db2e83ff03e65bbfed92e94ba449 Mon Sep 17 00:00:00 2001 From: Johannes Pinger Date: Thu, 8 Oct 2026 14:50:01 +0200 Subject: [PATCH] fix: Bound async state serialization so a blocked thread can't freeze or crash SanitizeForJSON walked the AsyncLocalStorage state without any limits while the captured thread was interrupted. Shared references were copied once per path, linked structures (e.g. Node timer lists reachable from a Sentry client) recursed once per node with an O(depth) cycle check, and exceptions from the walk escaped into the interrupted JavaScript. - Limit depth (10), entries per container (1000), values read (20000) and string length (1 MiB); cut data is replaced by visible markers - Copy own enumerable string keys only, into null-prototype containers so inherited toJSON hooks can't bypass the limits - Replace Proxies by "[Proxy]" without running traps - Catch exceptions during state serialization and keep V8 execution termination --- CHANGELOG.md | 12 + README.md | 34 +++ module.cc | 368 +++++++++++++++++++++++------- test/bounded-breadth.mjs | 13 ++ test/bounded-depth.mjs | 32 +++ test/bounded-getter-budget.mjs | 24 ++ test/bounded-harness.mjs | 93 ++++++++ test/bounded-key-after.mjs | 8 + test/bounded-key-first.mjs | 11 + test/bounded-node-budget.mjs | 11 + test/bounded-poll-state.mjs | 31 +++ test/bounded-proxy.mjs | 28 +++ test/bounded-semantics.mjs | 24 ++ test/bounded-shared-graph.mjs | 10 + test/bounded-strings.mjs | 12 + test/bounded-terminate-target.mjs | 46 ++++ test/bounded-terminate.mjs | 59 +++++ test/bounded-throwing-lookup.mjs | 15 ++ test/bounded-throwing.mjs | 27 +++ test/bounded-timers.mjs | 15 ++ test/bounded-tojson-array.mjs | 18 ++ test/bounded-tojson-object.mjs | 18 ++ test/bounded-tojson.mjs | 31 +++ test/bounded-watchdog.mjs | 34 +++ test/e2e.test.mjs | 344 +++++++++++++++++++++++++++- 25 files changed, 1235 insertions(+), 83 deletions(-) create mode 100644 test/bounded-breadth.mjs create mode 100644 test/bounded-depth.mjs create mode 100644 test/bounded-getter-budget.mjs create mode 100644 test/bounded-harness.mjs create mode 100644 test/bounded-key-after.mjs create mode 100644 test/bounded-key-first.mjs create mode 100644 test/bounded-node-budget.mjs create mode 100644 test/bounded-poll-state.mjs create mode 100644 test/bounded-proxy.mjs create mode 100644 test/bounded-semantics.mjs create mode 100644 test/bounded-shared-graph.mjs create mode 100644 test/bounded-strings.mjs create mode 100644 test/bounded-terminate-target.mjs create mode 100644 test/bounded-terminate.mjs create mode 100644 test/bounded-throwing-lookup.mjs create mode 100644 test/bounded-throwing.mjs create mode 100644 test/bounded-timers.mjs create mode 100644 test/bounded-tojson-array.mjs create mode 100644 test/bounded-tojson-object.mjs create mode 100644 test/bounded-tojson.mjs create mode 100644 test/bounded-watchdog.mjs 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]' }); + }); +});