From 4ba765bdb80d016ed0bd2e215f4aaac8e8c77698 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Oct 2026 20:09:15 -0700 Subject: [PATCH 1/3] fix(jsc): deliver capped-worker termination after GC --- .../qualification/engine-limit-probe.cpp | 68 +++++++++++++++++++ .../worker-resource-limits.test.ts | 8 +++ .github/openclaw/release-notes.md | 2 + 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, 91 insertions(+), 4 deletions(-) diff --git a/.github/openclaw/qualification/engine-limit-probe.cpp b/.github/openclaw/qualification/engine-limit-probe.cpp index 807fa542b6cd6..8c41f5374ded3 100644 --- a/.github/openclaw/qualification/engine-limit-probe.cpp +++ b/.github/openclaw/qualification/engine-limit-probe.cpp @@ -5,6 +5,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include @@ -41,6 +46,67 @@ static bool probe(const char* name, const char* source, size_t heapLimit, size_t 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; @@ -52,5 +118,7 @@ int main() 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/release-notes.md b/.github/openclaw/release-notes.md index 66197fc905888..1bb64df6efe24 100644 --- a/.github/openclaw/release-notes.md +++ b/.github/openclaw/release-notes.md @@ -23,3 +23,5 @@ The new engine requires Bun's updated mimalloc idle hook and matching embedding Source and licenses: LICENSE-SOURCES.txt. Checksums: SHA256SUMS and manifest.json. Build and qualification evidence: provenance.tar.gz. Rollback by restoring the prior Bun manifest and WebKit pin together, then rebuilding. Prior releases remain available. + +Worker heap-cap termination now invalidates optimized code at the mutator GC epilogue, avoiding repeated full collections while a pending termination trap waits for a signal. Full-GC managed live accounting and ArrayBuffer exclusions are unchanged. diff --git a/OPENCLAW.md b/OPENCLAW.md index 9a57972e004f8..b87c512beb6ce 100644 --- a/OPENCLAW.md +++ b/OPENCLAW.md @@ -150,6 +150,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. + - Prevent late JSC helper-thread allocation from overwriting a recycled pthread TLS key during process exit; add a deterministic native allocator-exit regression. Ports the allocator correction from oven-sh/WebKit#698, thanks @dylan-conway. ## ARM64 arithmetic and Windows artifacts (2026-10-05) 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 e0ce533a7be9caab39320a037660a31e799ca75b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Oct 2026 20:28:39 -0700 Subject: [PATCH 2/3] 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 From 7335155ebd04d6ba911d1a1b920a787c319fef25 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Oct 2026 21:04:10 -0700 Subject: [PATCH 3/3] test(jsc): count all worker heap qualification cases --- .github/openclaw/qualification/verify-sync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/openclaw/qualification/verify-sync.py b/.github/openclaw/qualification/verify-sync.py index ba51525209b73..c316d79e026ba 100644 --- a/.github/openclaw/qualification/verify-sync.py +++ b/.github/openclaw/qualification/verify-sync.py @@ -24,7 +24,7 @@ cache=json.loads((out/'stack-cache.json').read_text()) assert len(cache)==4 and all(r['matches']==r['total']==56 for r in cache if r['arm']=='candidate') assert cache[-1]['arm']=='candidate' and cache[-1]['repeat']=='warm' and cache[-1]['cache_hit_proven'] is True -assert len((out/'engine-limits.log').read_text().splitlines())==7 +assert len((out/'engine-limits.log').read_text().splitlines())==9 engine=json.loads((root/'engine-gate.json').read_text()) assert engine['passed'] and engine['source']==sys.argv[2] compatibility=json.loads((root/'compatibility-gate.json').read_text())