From 8349bde555efaebc920d6d2386353c4736235e68 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Oct 2026 20:14:26 -0700 Subject: [PATCH 1/2] fix(jsc): deliver capped-worker termination after GC --- .../qualification/engine-limit-probe.cpp | 124 ++++++++++++++++++ .../worker-resource-limits.test.ts | 8 ++ .github/openclaw/qualify.sh | 9 ++ OPENCLAW.md | 2 + Source/JavaScriptCore/heap/Heap.cpp | 7 + Source/JavaScriptCore/runtime/JSLock.cpp | 2 +- Source/JavaScriptCore/runtime/VMTraps.h | 2 +- .../JavaScriptCore/runtime/VMTrapsInlines.h | 4 +- 8 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 .github/openclaw/qualification/engine-limit-probe.cpp diff --git a/.github/openclaw/qualification/engine-limit-probe.cpp b/.github/openclaw/qualification/engine-limit-probe.cpp new file mode 100644 index 0000000000000..8c41f5374ded3 --- /dev/null +++ b/.github/openclaw/qualification/engine-limit-probe.cpp @@ -0,0 +1,124 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static void evaluate(JSGlobalContextRef context, const char* text) +{ + auto source = JSStringCreateWithUTF8CString(text); + JSValueRef exception = nullptr; + JSEvaluateScript(context, source, nullptr, nullptr, 1, &exception); + JSStringRelease(source); + if (exception) { + std::fprintf(stderr, "probe evaluation failed\n"); + std::exit(2); + } +} + +static bool probe(const char* name, const char* source, size_t heapLimit, size_t nursery, bool expected) +{ + auto group = JSContextGroupCreate(); + auto context = JSGlobalContextCreateInGroup(group, nullptr); + auto& vm = toJS(context)->vm(); + bool exceeded; + { + JSC::JSLockHolder lock(vm); + vm.heap.collectNow(JSC::Sync, JSC::CollectionScope::Full); + vm.heap.setWorkerHeapLimits(heapLimit, nursery); + evaluate(context, source); + vm.heap.collectNow(JSC::Sync, JSC::CollectionScope::Full); + exceeded = vm.heap.heapLimitExceeded(); + std::printf("%s managed=%zu exceeded=%d expected=%d\n", name, + vm.heap.sizeAfterLastFullCollectionExcludingArrayBuffers(), exceeded, expected); + } + JSGlobalContextRelease(context); + JSContextGroupRelease(group); + return exceeded == expected; +} + +class TerminationObserver final : public JSC::HeapObserver { +public: + explicit TerminationObserver(JSC::VM& vm) + : m_vm(vm) { } + void willGarbageCollect() final { } + void didGarbageCollect(JSC::CollectionScope scope) final + { + if (scope != JSC::CollectionScope::Full || !m_vm.heap.heapLimitExceeded()) + return; + if (++m_breaches != 1) { + std::fprintf(stderr, "worker termination was not delivered before another full GC\n"); + std::fflush(stderr); + std::_Exit(1); + } + m_firstBreach = WTF::MonotonicTime::now(); + m_vm.notifyNeedTermination(); + } + JSC::VM& m_vm; + unsigned m_breaches { 0 }; + WTF::MonotonicTime m_firstBreach; +}; + +static bool promptTerminationProbe() +{ + auto group = JSContextGroupCreate(); + auto context = JSGlobalContextCreateInGroup(group, nullptr); + auto& vm = toJS(context)->vm(); + WTF::BinarySemaphore ready, release, done; + // Keep the signal sender unavailable: GC must deliver the pending trap itself. + JSC::VMTraps::queue().dispatch([&] { + ready.signal(); + release.wait(); + done.signal(); + }); + ready.wait(); + bool passed; + { + JSC::JSLockHolder lock(vm); + // Embedders prepare this exception before requesting termination from a GC observer. + vm.ensureTerminationException(); + vm.heap.collectNow(JSC::Sync, JSC::CollectionScope::Full); + vm.heap.setWorkerHeapLimits(32 * 1024 * 1024, 4 * 1024 * 1024); + TerminationObserver observer(vm); + vm.heap.addObserver(&observer); + auto source = JSStringCreateWithUTF8CString("let held=[];for(let i=0;i<2000000;i++)held.push({i,a:i+1,b:i+2,c:i+3});"); + JSValueRef exception = nullptr; + JSEvaluateScript(context, source, nullptr, nullptr, 1, &exception); + auto end = WTF::MonotonicTime::now(); + JSStringRelease(source); + vm.heap.removeObserver(&observer); + release.signal(); + done.wait(); + passed = exception && observer.m_breaches == 1; + std::printf("termination exception=%d fullGCsAtOrAboveCap=%u latencyMs=%.3f passed=%d\n", !!exception, + observer.m_breaches, (end - observer.m_firstBreach).milliseconds(), passed); + } + JSGlobalContextRelease(context); + JSContextGroupRelease(group); + return passed; +} + +int main() +{ + constexpr size_t MB = 1024 * 1024; + bool passed = true; + passed &= probe("managed", "globalThis.held = new Array(8*1024*1024).fill(7)", 32*MB, 4*MB, true); + passed &= probe("oversize", "globalThis.held = new Uint8Array(128*1024*1024).fill(7)", 32*MB, 4*MB, false); + passed &= probe("fast", "globalThis.held=[];for(let i=0;i<65536;i++)held.push(new Uint8Array(2048).fill(7))", 32*MB, 4*MB, false); + passed &= probe("arraybuffer", "globalThis.held = new Uint8Array(new ArrayBuffer(128*1024*1024)).fill(7)", 32*MB, 4*MB, false); + passed &= probe("mixed", "globalThis.held=[new Uint8Array(128*1024*1024).fill(7),new Array(8*1024*1024).fill(7)]", 32*MB, 4*MB, true); + passed &= probe("unlimited-other-vm", "globalThis.held = new Array(8*1024*1024).fill(7)", 0, 0, false); + passed &= probe("young-only", "globalThis.held = new Array(8*1024*1024).fill(7)", 0, 4*MB, false); + passed &= probe("near-limit-churn", "globalThis.held = new Array(30*1024*1024/8).fill(7); for(let i=0;i<128;i++){let temporary=new Array(64*1024).fill(i); if(temporary[i]!==i)throw Error('corrupt');}", 32*MB, 4*MB, false); + passed &= promptTerminationProbe(); + return passed ? 0 : 1; +} diff --git a/.github/openclaw/qualification/worker-resource-limits.test.ts b/.github/openclaw/qualification/worker-resource-limits.test.ts index fa0d9a4b5e3b5..e2cff3a463bae 100644 --- a/.github/openclaw/qualification/worker-resource-limits.test.ts +++ b/.github/openclaw/qualification/worker-resource-limits.test.ts @@ -99,6 +99,14 @@ describe("resourceLimits", () => { expect({ code: result.error?.code, exit: result.exit }).toEqual({ code: "ERR_WORKER_OUT_OF_MEMORY", exit: 1 }); }); + test("near-limit managed churn survives with external backing stores", async () => { + const result = await runLimitedWorker({ maxOldGenerationSizeMb: 32, maxYoungGenerationSizeMb: 4 }, + "globalThis.held=new Array(22*1024*1024/8).fill(7);globalThis.external=new Uint8Array(64*1024*1024);external[0]=11;let sum=0;for(let i=0;i<128;i++){let temporary=new Array(64*1024).fill(i);sum+=temporary[i];}parentPort.postMessage({sum,length:held.length,value:held[0],external:external.byteLength,byte:external[0]});"); + expect({ events: result.events, exit: result.exit, value: result.messages.at(-1) }).toEqual({ + events: ["exit"], exit: 0, value: { sum: 8128, length: 2883584, value: 7, external: 67108864, byte: 11 }, + }); + }); + test("a young-only limit sizes the nursery without capping retained objects", async () => { const result = await runLimitedWorker({ maxYoungGenerationSizeMb: 4 }, "globalThis.held=[];for(let i=0;i<8;i++)held.push(new Array(1024*1024).fill(i));Bun.gc(true);parentPort.postMessage('retained');"); diff --git a/.github/openclaw/qualify.sh b/.github/openclaw/qualify.sh index 35cd5834339bf..ec47090592252 100644 --- a/.github/openclaw/qualify.sh +++ b/.github/openclaw/qualify.sh @@ -68,6 +68,15 @@ unset BUN_WEBKIT_ARTIFACT_MANIFEST BUN_BUILD_CACHE_DIR BUN_BUILD_PREFETCH_DIR "$candidate" test test/js/node/process/process.test.js -t 'ArrayBuffer memory ownership' > "$QUALIFICATION_DIR/arraybuffer.log" 2>&1 "$candidate" test test/js/node/worker_threads/worker_threads.test.ts -t 'getHeapStatistics' > "$QUALIFICATION_DIR/worker-statistics.log" 2>&1 "$candidate" test test/js/node/worker_threads/openclaw-resource-limits.test.ts -t 'resourceLimits' > "$QUALIFICATION_DIR/resource-limits.log" 2>&1 +mkdir -p "$QUALIFICATION_DIR/worker-engine" +tar -xzf "$ARTIFACT_DIR/bun-webkit-linux-amd64.tar.gz" -C "$QUALIFICATION_DIR/worker-engine" +WK="$QUALIFICATION_DIR/worker-engine/bun-webkit" +clang++-23 -std=c++23 -O2 -DNDEBUG -DBUILDING_WITH_CMAKE=1 -DHAVE_CONFIG_H=1 \ + -I"$WK/include" -I"$WK/include/JavaScriptCore" -I"$WK/include/wtf" \ + "$inputs/engine-limit-probe.cpp" "$QUALIFICATION_DIR/bun/build/qualify-candidate/obj/vendor/mimalloc/src/static.c.o" \ + -Wl,--start-group "$WK"/lib/*.a -Wl,--end-group -lpthread -ldl -lm -latomic \ + -o "$QUALIFICATION_DIR/engine-limit-probe" > "$QUALIFICATION_DIR/engine-limit-build.log" 2>&1 +"$QUALIFICATION_DIR/engine-limit-probe" > "$QUALIFICATION_DIR/engine-limits.log" 2>&1 "$candidate" "$inputs/module-context.mjs" matrix > "$QUALIFICATION_DIR/als-plugin.json" "$candidate" "$inputs/module-context.mjs" matrix --native-hooks > "$QUALIFICATION_DIR/als-native.json" python3 - "$QUALIFICATION_DIR" "$SOURCE_SHA" "$BUN_COMMIT" <<'PY' diff --git a/OPENCLAW.md b/OPENCLAW.md index 2c9eab7647f3f..b5592d15c77d9 100644 --- a/OPENCLAW.md +++ b/OPENCLAW.md @@ -145,6 +145,8 @@ baseline. Engine and Bun headers and libraries must be rebuilt together. ## Unreleased +- Deliver pending worker heap-limit termination promptly after GC by invalidating optimized code on the mutator, preserving full-GC live accounting and external-buffer exclusions. + - Preserve mimalloc pthread TLS-key ownership during shell exit by draining active setters before deletion; cover both late and in-flight allocations with native regressions. Builds on oven-sh/WebKit#698, thanks @dylan-conway. - Fix ARM64 MacroAssembler register-to-memory `add64` operand order, restoring DFG typed-array allocation accounting and preserving the source register; cover the operation in testmasm and native ARM64 qualification. diff --git a/Source/JavaScriptCore/heap/Heap.cpp b/Source/JavaScriptCore/heap/Heap.cpp index f39cd6d5cd522..cd502ed5237fe 100644 --- a/Source/JavaScriptCore/heap/Heap.cpp +++ b/Source/JavaScriptCore/heap/Heap.cpp @@ -110,6 +110,7 @@ #include "TypeProfilerLog.h" #include "UnlinkedEvalCodeBlock.h" #include "VM.h" +#include "VMTrapsInlines.h" #include "VerifierSlotVisitorInlines.h" #include "WasmCallee.h" #include "WeakMapImplInlines.h" @@ -3052,6 +3053,12 @@ void Heap::runCollectionEpilogue() if (shouldSweepSynchronously()) sweepSynchronously(); +#if USE(BUN_JSC_ADDITIONS) + // A capped worker may spend nearly all its time in GC, starving signal-based trap delivery. + if (m_heapLimitExceeded) + vm().traps().invalidateCodeBlocksForPendingTraps(); +#endif + if (Options::logGC()) [[unlikely]] { MonotonicTime after = MonotonicTime::now(); dataLog((after - before).milliseconds(), "ms]\n"); diff --git a/Source/JavaScriptCore/runtime/JSLock.cpp b/Source/JavaScriptCore/runtime/JSLock.cpp index 0fa9323dc1a11..c7a14d9fe7c3c 100644 --- a/Source/JavaScriptCore/runtime/JSLock.cpp +++ b/Source/JavaScriptCore/runtime/JSLock.cpp @@ -148,7 +148,7 @@ void JSLock::didAcquireLock() } // Note: everything below must come after addCurrentThread(). - m_vm->traps().notifyGrabAllLocks(); + m_vm->traps().invalidateCodeBlocksForPendingTraps(); #if ENABLE(SAMPLING_PROFILER) { diff --git a/Source/JavaScriptCore/runtime/VMTraps.h b/Source/JavaScriptCore/runtime/VMTraps.h index 0647efa966510..12dc154553452 100644 --- a/Source/JavaScriptCore/runtime/VMTraps.h +++ b/Source/JavaScriptCore/runtime/VMTraps.h @@ -227,7 +227,7 @@ class VMTraps { inline void deferTermination(DeferAction); inline void undoDeferTermination(DeferAction); - inline void notifyGrabAllLocks(); + inline void invalidateCodeBlocksForPendingTraps(); bool hasTrapBit(Event event) { diff --git a/Source/JavaScriptCore/runtime/VMTrapsInlines.h b/Source/JavaScriptCore/runtime/VMTrapsInlines.h index 7890ae42778d4..2988a7257e4e8 100644 --- a/Source/JavaScriptCore/runtime/VMTrapsInlines.h +++ b/Source/JavaScriptCore/runtime/VMTrapsInlines.h @@ -67,9 +67,9 @@ ALWAYS_INLINE DeferTraps::~DeferTraps() m_traps.m_trapsDeferred = m_previousTrapsDeferred; } -inline void VMTraps::notifyGrabAllLocks() +inline void VMTraps::invalidateCodeBlocksForPendingTraps() { - if (needHandling(AsyncEvents)) + if (!m_trapsDeferred && needHandling(AsyncEvents)) invalidateCodeBlocksOnStack(); } From 095a11a7467346920209142bb22974d213579598 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Oct 2026 20:28:40 -0700 Subject: [PATCH 2/2] test(jsc): use the packaged semaphore header path --- .github/openclaw/qualification/engine-limit-probe.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/openclaw/qualification/engine-limit-probe.cpp b/.github/openclaw/qualification/engine-limit-probe.cpp index 8c41f5374ded3..15749213b709b 100644 --- a/.github/openclaw/qualification/engine-limit-probe.cpp +++ b/.github/openclaw/qualification/engine-limit-probe.cpp @@ -7,7 +7,7 @@ #include #include #include -#include +#include #include #include #include