diff --git a/src/jsc/modules/BunJSCModule.h b/src/jsc/modules/BunJSCModule.h index 253567844d19..64261375d8d2 100644 --- a/src/jsc/modules/BunJSCModule.h +++ b/src/jsc/modules/BunJSCModule.h @@ -227,6 +227,7 @@ JSC_DEFINE_HOST_FUNCTION(functionMemoryUsageStatistics, { auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); if (vm.heap.size() == 0) { vm.heap.collectNow(Sync, CollectionScope::Full); @@ -375,6 +376,7 @@ JSC_DEFINE_HOST_FUNCTION(functionMemoryUsageStatistics, if (char* json = mi_stats_get_json(0, nullptr)) { JSValue parsed = JSONParse(globalObject, String::fromUTF8(json)); mi_free(json); + RETURN_IF_EXCEPTION(scope, {}); object->putDirect(vm, Identifier::fromString(vm, "mimalloc"_s), parsed.isEmpty() ? jsNull() : parsed); } @@ -383,8 +385,10 @@ JSC_DEFINE_HOST_FUNCTION(functionMemoryUsageStatistics, JSValue arg0 = callFrame->argument(0); if (arg0.isObject()) { JSValue dump = arg0.getObject()->get(globalObject, Identifier::fromString(vm, "dump"_s)); + RETURN_IF_EXCEPTION(scope, {}); if (dump.toBoolean(globalObject)) { const bool includeBlocks = dump.isString() && dump.toWTFString(globalObject) == "blocks"_s; + RETURN_IF_EXCEPTION(scope, {}); #if BUN_DEBUG const bool hashAddresses = false; #else @@ -393,6 +397,7 @@ JSC_DEFINE_HOST_FUNCTION(functionMemoryUsageStatistics, if (char* json = mi_heap_dump_json(includeBlocks, hashAddresses)) { JSValue parsed = JSONParse(globalObject, String::fromUTF8(json)); mi_free(json); + RETURN_IF_EXCEPTION(scope, {}); object->putDirect(vm, Identifier::fromString(vm, "mimallocDump"_s), parsed.isEmpty() ? jsNull() : parsed); } diff --git a/test/js/bun/jsc/heapStats-mimalloc.test.ts b/test/js/bun/jsc/heapStats-mimalloc.test.ts index cefdd78762e5..3f7b34480269 100644 --- a/test/js/bun/jsc/heapStats-mimalloc.test.ts +++ b/test/js/bun/jsc/heapStats-mimalloc.test.ts @@ -91,3 +91,55 @@ describe("heapStats() mimalloc integration", () => { expect(exitCode).toBe(0); }); }); + +describe("heapStats() exception safety", () => { + // heapStats() JSON-parses mimalloc stats and reads the dump option off its + // argument; each of those can throw. BUN_JSC_validateExceptionChecks=1 makes + // a debug build abort if one of those throws could go unchecked (release + // builds ignore the option and just exercise the paths). The getter/Proxy + // cases pin that a user exception from the dump lookup propagates instead of + // taking the dump with an exception pending. + test("dump option reads and JSON parses keep no pending exception", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const jsc = require("bun:jsc"); + jsc.heapStats(); + jsc.heapStats({ dump: true }); + + // A rope string reaches the rope-resolving path of the dump mode read. + let suffix = "cks"; + const mode = "blo" + suffix; + if (!jsc.isRope(mode)) throw new Error("expected a rope string"); + const blocks = jsc.heapStats({ dump: mode }); + if (!Array.isArray(blocks.mimallocDump.heaps)) throw new Error("expected heaps in blocks dump"); + + let caught; + try { + jsc.heapStats({ get dump() { throw new Error("boom"); } }); + } catch (e) { caught = e.message; } + if (caught !== "boom") throw new Error("dump getter error did not propagate: " + caught); + + let trapCaught; + try { + jsc.heapStats(new Proxy({}, { get() { throw new Error("trap"); } })); + } catch (e) { trapCaught = e.message; } + if (trapCaught !== "trap") throw new Error("proxy trap error did not propagate: " + trapCaught); + + const after = jsc.heapStats({ dump: true }); + if (!after.mimallocDump) throw new Error("heapStats broken after thrown dump read"); + console.log("OK");`, + ], + env: { ...bunEnv, BUN_JSC_validateExceptionChecks: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const uncheckedScopes = stderr + .split("\n") + .map(line => line.trim()) + .filter(line => line.startsWith("This scope can throw") || line.startsWith("But the exception was unchecked")); + expect({ stdout, uncheckedScopes, exitCode }).toEqual({ stdout: "OK\n", uncheckedScopes: [], exitCode: 0 }); + }); +});