diff --git a/.github/openclaw/qualification/patches/011-bun-gc-timer-wake.patch b/.github/openclaw/qualification/patches/011-bun-gc-timer-wake.patch new file mode 100644 index 0000000000000..8e3657202de93 --- /dev/null +++ b/.github/openclaw/qualification/patches/011-bun-gc-timer-wake.patch @@ -0,0 +1,46 @@ +diff --git a/src/runtime/timer/WTFTimer.rs b/src/runtime/timer/WTFTimer.rs +index 9a0841e58..053e9c582 100644 +--- a/src/runtime/timer/WTFTimer.rs ++++ b/src/runtime/timer/WTFTimer.rs +@@ -43,6 +43,8 @@ pub(crate) struct WTFTimer { + // that owns this wrapper lives on the VM's run loop, so the VM outlives + // the timer. + vm: NonNull, ++ // An uncounted gate can wake the owner without keeping its VM alive. ++ wake_handle: bun_jsc::VmHandle, + // FFI handle into WebKit's RunLoop::TimerBase; owned by C++. + run_loop_timer: NonNull, + pub(crate) event_loop_timer: EventLoopTimer, +@@ -130,12 +132,18 @@ impl WTFTimer { + // There's only one of these per VM, and each VM has its own imminent_gc_timer. + // Only set imminent if it's not already set to avoid overwriting another timer. + if seconds.partial_cmp(&0.0) != Some(core::cmp::Ordering::Greater) { +- let _ = imminent.compare_exchange( +- ptr::null_mut(), +- self_opaque, +- Ordering::SeqCst, +- Ordering::SeqCst, +- ); ++ let wake_handle = t.wake_handle.clone(); ++ if imminent ++ .compare_exchange( ++ ptr::null_mut(), ++ self_opaque, ++ Ordering::SeqCst, ++ Ordering::SeqCst, ++ ) ++ .is_ok() ++ { ++ wake_handle.wake(); ++ } + return; + } + // Clear imminent if this timer was the one that set it. +@@ -257,6 +265,7 @@ unsafe extern "C" fn WTFTimer__create(run_loop_timer: *mut RunLoopTimer) -> *mut + let el = &*vm_ref.event_loop(); + Box::new(WTFTimer { + vm: NonNull::new_unchecked(vm), ++ wake_handle: vm_ref.handle(), + imminent: bun_ptr::BackRef::new(&el.imminent_gc_timer), + event_loop_timer: EventLoopTimer { + next: ElTimespec { diff --git a/.github/openclaw/qualification/verify-sync.py b/.github/openclaw/qualification/verify-sync.py index c316d79e026ba..27c311929738f 100644 --- a/.github/openclaw/qualification/verify-sync.py +++ b/.github/openclaw/qualification/verify-sync.py @@ -10,7 +10,7 @@ assert rows and all(r['ok'] for r in rows),'sync CI failures' assert {'test/js/web/atomics.test.ts','test/js/bun/wasm/compile-rss.test.ts'}<=set(selected) counts={} -for name,minimum in [('memory-release',3),('proxy',4),('resource-limits',14),('namespace',41),('inspector',78),('als-suites',223),('stack-positions',343),('stack-minifier',68),('arraybuffer',19),('worker-statistics',7),('arraybuffer-native',1),('arraybuffer-serializer',60),('inspection-idle',1)]: +for name,minimum in [('memory-release',3),('proxy',4),('resource-limits',16),('namespace',41),('inspector',78),('als-suites',223),('stack-positions',343),('stack-minifier',68),('arraybuffer',19),('worker-statistics',7),('arraybuffer-native',1),('arraybuffer-serializer',60),('inspection-idle',1)]: text=(out/(name+'.log')).read_text() passes=re.findall(r'(\d+) pass',text) assert passes and int(passes[-1])>=minimum,name+' incomplete' diff --git a/.github/openclaw/qualification/worker-resource-limits.test.ts b/.github/openclaw/qualification/worker-resource-limits.test.ts index e2cff3a463bae..8a8a4cf8b9a72 100644 --- a/.github/openclaw/qualification/worker-resource-limits.test.ts +++ b/.github/openclaw/qualification/worker-resource-limits.test.ts @@ -36,7 +36,7 @@ describe("resourceLimits", () => { expect(reads).toBe(2); }); - async function runLimitedWorker(limits: object, body: string) { + async function runLimitedWorker(limits: object, body: string, env: Record = {}) { await using child = Bun.spawn({ cmd: [bunExe(), "-e", ` const {Worker,resourceLimits}=require('node:worker_threads'); @@ -47,7 +47,7 @@ describe("resourceLimits", () => { w.on('error',e=>{result.events.push('error');result.error={name:e.name,code:e.code,message:e.message,limits:w.resourceLimits};}); w.on('exit',code=>{result.events.push('exit');result.exit=code;result.after=w.resourceLimits;console.log(JSON.stringify(result));}); `], - env: bunEnv, + env: { ...bunEnv, ...env }, stdout: "pipe", stderr: "pipe", }); @@ -69,6 +69,62 @@ describe("resourceLimits", () => { expect({ online: result.online, inside: result.messages[0].limits, after: result.after, exit: result.exit }).toEqual({ online: limits, inside: limits, after: {}, exit: 0 }); }); + const withoutIdleGC = { + BUN_GC_TIMER_DISABLE: "1", + BUN_IDLE_GC_SECONDS: "0", + BUN_DISABLE_STOP_IF_NECESSARY_TIMER: "1", + BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS: "0", + }; + + test.concurrent("allows natural young collections below the old heap limit", async () => { + const result = await runLimitedWorker( + { maxOldGenerationSizeMb: 64, maxYoungGenerationSizeMb: 1 }, + ` + const { GCProfiler } = require('node:v8'); + const profiler = new GCProfiler(); + profiler.start(); + let checksum = 0; + for (let round = 0; round < 1000; ++round) { + globalThis.batch = new Array(8192).fill(round); + checksum += batch[(round * 31) % batch.length]; + } + const records = profiler.stop().statistics; + parentPort.postMessage({ checksum, young: records.filter(row => row.gcType === 'Scavenge').length }); + `, + withoutIdleGC, + ); + expect(result.messages.at(-1).checksum).toBe(499500); + expect(result.messages.at(-1).young).toBeGreaterThan(1); + expect({ events: result.events, exit: result.exit }).toEqual({ events: ["exit"], exit: 0 }); + }); + + test.concurrent("verifies an old-limit crossing found by Eden before an idle worker parks", async () => { + const result = await runLimitedWorker( + { maxOldGenerationSizeMb: 16, maxYoungGenerationSizeMb: 64 }, + ` + const { edenGC } = require('bun:jsc'); + parentPort.on('message', () => {}); + globalThis.held = new Array(80000); + Bun.gc(true); + let checksum = 0; + for (let i = 0; i < held.length; ++i) { + const text = String(i).padStart(220, 'x'); + checksum += text.charCodeAt(0) + text.charCodeAt(219); + held[i] = text; + } + parentPort.postMessage(checksum); + edenGC(); + `, + withoutIdleGC, + ); + expect(result.messages.at(-1)).toBe(13800000); + expect({ events: result.events, code: result.error?.code, exit: result.exit }).toEqual({ + events: ["error", "exit"], + code: "ERR_WORKER_OUT_OF_MEMORY", + exit: 1, + }); + }); + test("terminates only the worker with the Node heap OOM event contract", async () => { const result = await runLimitedWorker({ maxOldGenerationSizeMb: 32, maxYoungGenerationSizeMb: 4 }, "globalThis.held=[];for(let i=0;i<64;i++)held.push(new Array(1024*1024).fill(i));parentPort.postMessage('survived');"); diff --git a/.github/openclaw/qualify-arm64.sh b/.github/openclaw/qualify-arm64.sh index a6cfd8fd1dceb..163c2a0a7c964 100644 --- a/.github/openclaw/qualify-arm64.sh +++ b/.github/openclaw/qualify-arm64.sh @@ -10,9 +10,9 @@ jsc="$ENGINE_BUILD_DIR/bin/jsc" "$ENGINE_BUILD_DIR/bin/testMimallocExit" > "$QUALIFICATION_DIR/mimalloc-exit.log" 2>&1 "$ENGINE_BUILD_DIR/bin/testMimallocExitInFlight" > "$QUALIFICATION_DIR/mimalloc-exit-in-flight.log" 2>&1 Tools/Scripts/run-jsc-stress-tests JSTests/stress --jsc "$jsc" --child-processes "${ENGINE_TEST_JOBS:-4}" \ - --filter 'promise|microtask|dynamic-import|bun-async-context|module-loader|top-level-await|async-module|module-namespace|intl-segmenter|callsite-syntax-positions|stack-position-regressions|error-stack' \ + --filter 'promise|microtask|dynamic-import|bun-async-context|module-loader|top-level-await|async-module|module-namespace|intl-segmenter|callsite-syntax-positions|stack-position-regressions|error-stack|worker-heap-generational-cadence' \ --output-dir "$QUALIFICATION_DIR/jsc-stress" --no-retry > "$QUALIFICATION_DIR/jsc-stress.log" 2>&1 -python3 .github/openclaw/verify-jsc-results.py "$QUALIFICATION_DIR/jsc-stress" --require module-namespace-esmodule-marker.js --require callsite-syntax-positions.js --require stack-position-regressions.js +python3 .github/openclaw/verify-jsc-results.py "$QUALIFICATION_DIR/jsc-stress" --require module-namespace-esmodule-marker.js --require callsite-syntax-positions.js --require stack-position-regressions.js --require worker-heap-generational-cadence.js Tools/Scripts/run-jsc-stress-tests JSTests/modules.yaml --jsc "$jsc" --child-processes "${ENGINE_TEST_JOBS:-4}" \ --output-dir "$QUALIFICATION_DIR/jsc-modules" --no-retry > "$QUALIFICATION_DIR/jsc-modules.log" 2>&1 # Accounting requires generational GC; run its established explicit modes below. diff --git a/.github/openclaw/qualify-engine.sh b/.github/openclaw/qualify-engine.sh index 5782a9d8245ef..860c602e7e761 100644 --- a/.github/openclaw/qualify-engine.sh +++ b/.github/openclaw/qualify-engine.sh @@ -8,9 +8,9 @@ tar -xzf "$ARTIFACT_DIR/bun-webkit-linux-amd64.tar.gz" -C "$QUALIFICATION_DIR/en jsc="$QUALIFICATION_DIR/engine/bun-webkit/bin/jsc" "$jsc" --useDollarVM=true -e 'if(typeof $vm.moduleNamespaceForRequire!=="function")throw Error("namespace hook absent")' Tools/Scripts/run-jsc-stress-tests JSTests/stress --jsc "$jsc" --child-processes 16 \ - --filter 'promise|microtask|dynamic-import|bun-async-context|module-loader|top-level-await|async-module|module-namespace|intl-segmenter|callsite-syntax-positions|stack-position-regressions|error-stack' \ + --filter 'promise|microtask|dynamic-import|bun-async-context|module-loader|top-level-await|async-module|module-namespace|intl-segmenter|callsite-syntax-positions|stack-position-regressions|error-stack|worker-heap-generational-cadence' \ --output-dir "$QUALIFICATION_DIR/jsc-stress" --no-retry > "$QUALIFICATION_DIR/jsc-stress.log" 2>&1 -python3 .github/openclaw/verify-jsc-results.py "$QUALIFICATION_DIR/jsc-stress" --require module-namespace-esmodule-marker.js --require callsite-syntax-positions.js --require stack-position-regressions.js --require stack-position-regressions-cache.js +python3 .github/openclaw/verify-jsc-results.py "$QUALIFICATION_DIR/jsc-stress" --require module-namespace-esmodule-marker.js --require callsite-syntax-positions.js --require stack-position-regressions.js --require stack-position-regressions-cache.js --require worker-heap-generational-cadence.js python3 .github/openclaw/check-stack-visitor.py "$jsc" > "$QUALIFICATION_DIR/stack-visitor.log" WEBKIT_CHECKOUT="$PWD" JSC="$jsc" RESULT_DIR="$QUALIFICATION_DIR/jsc-stack" bash .github/openclaw/qualification/stack/check-jsc.sh Tools/Scripts/run-jsc-stress-tests JSTests/modules.yaml --jsc "$jsc" --child-processes 16 \ diff --git a/.github/openclaw/qualify-sync.sh b/.github/openclaw/qualify-sync.sh index 8ed0695cbebe5..3229a5884f3cd 100644 --- a/.github/openclaw/qualify-sync.sh +++ b/.github/openclaw/qualify-sync.sh @@ -27,6 +27,7 @@ for patch in 008-namespace.patch 009-allocation-sampling.patch 010-als-regressio git apply --index --exclude=CHANGELOG.md --exclude='docs/*' "$inputs/patches/$patch" done git apply --index "$inputs/sync/inspect-stack-position.patch" +git apply --index "$inputs/patches/011-bun-gc-timer-wake.patch" cp "$inputs/worker-resource-limits.test.ts" test/js/node/worker_threads/openclaw-resource-limits.test.ts git add test/js/node/worker_threads/openclaw-resource-limits.test.ts python3 "$inputs/stage-sync-artifact.py" "$ARTIFACT_DIR/bun-webkit-linux-amd64.tar.gz" --version "$SOURCE_SHA" --output "$QUALIFICATION_DIR/sync-input" diff --git a/.github/openclaw/qualify.sh b/.github/openclaw/qualify.sh index 6d680d26b2354..73d619bb420b3 100644 --- a/.github/openclaw/qualify.sh +++ b/.github/openclaw/qualify.sh @@ -30,6 +30,7 @@ for arm in baseline candidate; do git apply --exclude=CHANGELOG.md --exclude='docs/*' "$inputs/patches/008-namespace.patch" git apply --exclude=CHANGELOG.md --exclude='docs/*' "$inputs/patches/009-stack-positions.patch" git apply --exclude=CHANGELOG.md --exclude='docs/*' "$inputs/patches/010-arraybuffer-accounting.patch" + git apply "$inputs/patches/011-bun-gc-timer-wake.patch" cp "$inputs/worker-resource-limits.test.ts" test/js/node/worker_threads/openclaw-resource-limits.test.ts git diff --check git diff --binary HEAD > "$QUALIFICATION_DIR/bun-adapters.patch" @@ -81,12 +82,12 @@ assert faith['selected_files']==44 and faith['result_files']==46 for name in ['als-plugin','als-native']: rows=json.loads((root/(name+'.json')).read_text())['results'] assert len(rows)==14 and all(r['exitCode']==0 for r in rows) -for name,minimum in [('proxy',4),('resource-limits',14),('namespace',41),('arraybuffer',19),('worker-statistics',7)]: +for name,minimum in [('proxy',4),('resource-limits',16),('namespace',41),('arraybuffer',19),('worker-statistics',7)]: text=(root/(name+'.log')).read_text() passes=re.search(r'(\d+) pass',text) assert passes and int(passes[1])>=minimum, name+' missing passing cases' assert re.search(r'\b0 fail\b',text),name+' failures' (root/'gate.json').write_text(json.dumps({'passed':True,'source':sys.argv[2],'bun_commit':sys.argv[3], - 'selected_files':44,'result_files':46,'als_variants_per_mode':14,'proxy_minimum':4,'resource_minimum':14, + 'selected_files':44,'result_files':46,'als_variants_per_mode':14,'proxy_minimum':4,'resource_minimum':16, 'bun_adapter_sha256':hashlib.sha256((root/'bun-adapters.patch').read_bytes()).hexdigest()},indent=2)+'\n') PY diff --git a/JSTests/stress/worker-heap-generational-cadence.js b/JSTests/stress/worker-heap-generational-cadence.js new file mode 100644 index 0000000000000..ba5e1a3e9391f --- /dev/null +++ b/JSTests/stress/worker-heap-generational-cadence.js @@ -0,0 +1,23 @@ +//@ runDefault("--useDollarVM=true", "--useConcurrentGC=false") + +$vm.setWorkerHeapLimits(64 * 1024 * 1024, 256 * 1024); +const allocatedBefore = $vm.heapTotalBytesAllocated(); +let sawEden = false; +let checksum = 0; +const rounds = Math.max(80, Math.min(testLoopCount, 160)); +for (let round = 0; round < rounds; ++round) { + globalThis.lastBatch = new Array(2048).fill(round); + checksum += globalThis.lastBatch[round % 2048]; + sawEden ||= $vm.lastGCWasFull() === false; +} +if (checksum !== rounds * (rounds - 1) / 2) + throw new Error("allocation workload result changed"); +if ($vm.heapTotalBytesAllocated() - allocatedBefore < 1024 * 1024) + throw new Error("allocation workload did not exercise collection pressure"); +if (!sawEden) + throw new Error("worker limits forced every allocation collection full"); +if ($vm.workerHeapLimitExceeded()) + throw new Error("temporary allocation exceeded a live-heap limit"); +globalThis.lastBatch = null; +$vm.setWorkerHeapLimits(0, 0); +gc(); diff --git a/OPENCLAW.md b/OPENCLAW.md index 027b46ec3237a..6d30921ba8d42 100644 --- a/OPENCLAW.md +++ b/OPENCLAW.md @@ -46,7 +46,7 @@ Qualification-only Bun adapters live under `.github/openclaw/qualification`; they do not update or publish the Bun fork. The job runs the original 44-file fork selector output plus its two dependency checks, requires identical all-pass results, and checks Segmenter, four Proxy regressions, both 14-variant ALS hook -modes and at least 14 worker resource-limit cases. Other platforms are built; +modes and at least 16 worker resource-limit cases. Other platforms are built; this gate does not claim native runtime qualification on every architecture. A Windows 11 ARM64 job verifies archive provenance and native ARM64 PE identity, then runs JSC startup, DFG JIT, testFFI and ArrayBuffer accounting in five execution modes. Assembly and publication require @@ -150,6 +150,9 @@ baseline. Engine and Bun headers and libraries must be rebuilt together. ## Unreleased +- Let workers use young-generation collections within their heap budgets; verify exhaustion with a full collection and preserve progress while idle through the matching Bun timer adapter. +- Bound budgeted workers' post-full growth independently of machine-wide RAM, while retaining the collector minimum, separate nursery pacing and full-only heap-limit verification. + ## Worker termination and allocator exit (2026-10-06) - 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. diff --git a/Source/JavaScriptCore/heap/Heap.cpp b/Source/JavaScriptCore/heap/Heap.cpp index cd502ed5237fe..1947953da14be 100644 --- a/Source/JavaScriptCore/heap/Heap.cpp +++ b/Source/JavaScriptCore/heap/Heap.cpp @@ -2507,12 +2507,34 @@ NEVER_INLINE bool Heap::runEndPhase(GCConductor conn) dataLog("p=", thisPauseMS, "ms (max ", maxPauseMS(thisPauseMS), "), cycle ", (m_afterGC - m_beforeGC).milliseconds(), "ms END]\n"); } + Ticket workerVerificationTicket = 0; { Locker locker { *m_threadLock }; m_requests.removeFirst(); m_lastServedTicket++; +#if USE(BUN_JSC_ADDITIONS) + if (endingCollectionScope == CollectionScope::Full) { + auto completed = m_lastServedTicket; + m_workerVerificationTicket.compare_exchange_strong(completed, 0, std::memory_order_acq_rel); + } + if (endingCollectionScope == CollectionScope::Eden && m_embedderMaxHeapSize + && m_workerHeapSizeUpperBound >= m_embedderMaxHeapSize && m_isSafeToCollect && !m_isShuttingDown && !m_threadShouldStop) { + if (m_requests.isEmpty()) { + RELEASE_ASSERT(m_lastGrantedTicket != std::numeric_limits::max()); + m_requests.append(GCRequest(CollectionScope::Full)); + ++m_lastGrantedTicket; + } + workerVerificationTicket = m_lastServedTicket + 1; + } +#endif clearMutatorWaiting(); } +#if USE(BUN_JSC_ADDITIONS) + if (workerVerificationTicket) + scheduleWorkerHeapVerification(workerVerificationTicket); +#else + UNUSED_PARAM(workerVerificationTicket); +#endif ParkingLot::unparkAll(&m_worldState); dataLogLnIf(Options::logGC(), "GC END!"); @@ -3097,6 +3119,28 @@ void Heap::waitForCollection(Ticket ticket) }); } +#if USE(BUN_JSC_ADDITIONS) +void Heap::scheduleWorkerHeapVerification(Ticket ticket) +{ + if (!m_isSafeToCollect || m_isShuttingDown || m_threadShouldStop) + return; + auto previous = m_workerVerificationTicket.load(std::memory_order_acquire); + do { + if (previous >= ticket) + return; + } while (!m_workerVerificationTicket.compare_exchange_weak(previous, ticket, std::memory_order_acq_rel)); + m_stopIfNecessaryTimer->scheduleWorkerHeapVerification(); +} + +void Heap::finishWorkerHeapVerification() +{ + while (auto ticket = m_workerVerificationTicket.load(std::memory_order_acquire)) { + waitForCollection(ticket); + m_workerVerificationTicket.compare_exchange_strong(ticket, 0, std::memory_order_acq_rel); + } +} +#endif + void Heap::sweepEagerlyInEpilogue() { m_objectSpace.sweepPreciseAllocations(); @@ -3127,6 +3171,10 @@ bool Heap::suspendCompilerThreads() void Heap::willStartCollection() { ++m_gcVersion; +#if USE(BUN_JSC_ADDITIONS) + if (workerRequiresFullCollection()) + scheduleWorkerHeapVerification(m_lastServedTicket + 1); +#endif if (Options::verifyGC()) [[unlikely]] { m_verifierSlotVisitor = makeUnique(*this); ASSERT(!m_isMarkingForGCVerifier); @@ -3250,26 +3298,58 @@ void Heap::setWorkerHeapLimits(size_t heapBytes, size_t edenBytes) { m_embedderMaxHeapSize = heapBytes; m_embedderMaxEdenSize = edenBytes; - applyWorkerAllocationLimits(m_sizeAfterLastCollect); + m_workerHasFullAccounting = false; + updateWorkerHeapAccounting(false); + applyWorkerAllocationLimits(); } -void Heap::applyWorkerAllocationLimits(size_t currentHeapSize) +void Heap::updateWorkerHeapAccounting(bool isFullCollection) { - if (m_embedderMaxEdenSize) - m_maxEdenSize = std::min(m_maxEdenSize, m_embedderMaxEdenSize); - if (m_embedderMaxHeapSize) { - // Allocation pressure requests a full GC; only the post-full-GC live set may terminate a VM. - size_t remaining = m_embedderMaxHeapSize > m_sizeAfterLastFullCollectExcludingArrayBuffers - ? m_embedderMaxHeapSize - m_sizeAfterLastFullCollectExcludingArrayBuffers : 1; - m_maxEdenSize = std::min(m_maxEdenSize, remaining); - m_shouldDoFullCollection = true; + if (isFullCollection) { + m_workerBufferBytesAfterFullCollection = CheckedSize(m_fastArrayBufferBytesVisited.load(std::memory_order_relaxed)); + m_workerBufferBytesAfterFullCollection += m_externalArrayBufferBytesVisited.load(std::memory_order_relaxed); + m_workerHasFullAccounting = true; } - if (m_embedderMaxHeapSize || m_embedderMaxEdenSize) { - CheckedSize nextLimit = currentHeapSize; - nextLimit += std::max(1, m_maxEdenSize); - if (!nextLimit.hasOverflowed()) - m_maxHeapSize = std::min(m_maxHeapSize, nextLimit.value()); + // Eden preserves these old-generation charges. Only the preceding full's + // typed-vector deduction stays paired with them; ArrayBuffer bytes stay out. + CheckedSize accounted = m_totalBytesVisited; + accounted += m_extraMemorySize.loadRelaxed(); + accounted += m_deprecatedExtraMemorySize; + if (accounted.hasOverflowed() || (m_workerHasFullAccounting && m_workerBufferBytesAfterFullCollection.hasOverflowed())) + m_workerHeapSizeUpperBound = std::numeric_limits::max(); + else { + size_t vectors = m_workerHasFullAccounting ? m_workerBufferBytesAfterFullCollection.value() : 0; + ASSERT(accounted.value() >= vectors); + m_workerHeapSizeUpperBound = accounted.value() >= vectors + ? accounted.value() - vectors : std::numeric_limits::max(); } + m_workerAllocatedAtLastCollection = totalBytesAllocated(); +} + +size_t Heap::remainingWorkerAllocationBudget() const +{ + if (!m_embedderMaxHeapSize) + return std::numeric_limits::max(); + size_t remaining = m_embedderMaxHeapSize > m_workerHeapSizeUpperBound + ? m_embedderMaxHeapSize - m_workerHeapSizeUpperBound : 0; + uint64_t total = totalBytesAllocated(); + if (total < m_workerAllocatedAtLastCollection) + return 0; + uint64_t allocated = total - m_workerAllocatedAtLastCollection; + return allocated >= remaining ? 0 : remaining - static_cast(allocated); +} + +bool Heap::workerRequiresFullCollection() const +{ + return m_embedderMaxHeapSize && (!m_workerHasFullAccounting || !remainingWorkerAllocationBudget()); +} + +void Heap::applyWorkerAllocationLimits() +{ + if (m_embedderMaxEdenSize) + m_maxEdenSize = std::min(m_maxEdenSize, m_embedderMaxEdenSize); + if (m_embedderMaxHeapSize) + m_maxEdenSize = std::min(m_maxEdenSize, std::max(1, remainingWorkerAllocationBudget())); } #endif @@ -3314,7 +3394,8 @@ void Heap::updateAllocationLimits() // the new allocation limit based on the current size of the heap, with a // fixed minimum. size_t lastMaxHeapSize = m_maxHeapSize; - m_maxHeapSize = std::max(minHeapSize(m_heapType, m_ramSize), proportionalHeapSize(currentHeapSize, m_ramSize)); + size_t minimumHeapSize = minHeapSize(m_heapType, m_ramSize); + m_maxHeapSize = std::max(minimumHeapSize, proportionalHeapSize(currentHeapSize, m_ramSize)); m_maxEdenSize = m_maxHeapSize - currentHeapSize; if (m_isInOpportunisticTask && !isCritical) { // After an Opportunistic Full GC, we allow eden to occupy all the space we recovered. @@ -3323,6 +3404,16 @@ void Heap::updateAllocationLimits() // Eden GC to ensure that eden can grow to at least m_maxHeapSize. m_maxHeapSize = std::max(m_maxHeapSize, lastMaxHeapSize); } +#if USE(BUN_JSC_ADDITIONS) + if (m_embedderMaxHeapSize) { + // Each budgeted worker shares the machine's RAM with other VMs. + CheckedSize workerGrowthCeiling = currentHeapSize; + workerGrowthCeiling += currentHeapSize; + if (!workerGrowthCeiling.hasOverflowed()) + m_maxHeapSize = std::min(m_maxHeapSize, std::max(minimumHeapSize, workerGrowthCeiling.value())); + m_maxEdenSize = std::min(m_maxEdenSize, m_maxHeapSize - currentHeapSize); + } +#endif dataLogLnIf(verbose, "Full: maxHeapSize = ", m_maxHeapSize); dataLogLnIf(verbose, "Full: maxEdenSize = ", m_maxEdenSize); m_sizeAfterLastFullCollect = currentHeapSize; @@ -3356,8 +3447,10 @@ void Heap::updateAllocationLimits() m_sizeAfterLastFullCollectExcludingArrayBuffers = currentHeapSize > buffers ? currentHeapSize - buffers : 0; m_heapLimitExceeded = m_embedderMaxHeapSize && m_sizeAfterLastFullCollectExcludingArrayBuffers > m_embedderMaxHeapSize; } + if (m_embedderMaxHeapSize) + updateWorkerHeapAccounting(m_collectionScope == CollectionScope::Full); if (m_embedderMaxHeapSize || m_embedderMaxEdenSize) - applyWorkerAllocationLimits(currentHeapSize); + applyWorkerAllocationLimits(); if (std::exchange(m_reenableEdenActivityCallback, false) && m_edenActivityCallback) m_edenActivityCallback->setEnabled(true); if (std::exchange(m_reenableFullActivityCallback, false) && m_fullActivityCallback) @@ -3550,6 +3643,10 @@ bool Heap::shouldDoFullCollection() { if (!useGenerationalGC()) return true; +#if USE(BUN_JSC_ADDITIONS) + if (workerRequiresFullCollection()) + return true; +#endif if (!m_currentRequest.scope) return m_shouldDoFullCollection || overCriticalMemoryThreshold(); @@ -3742,6 +3839,10 @@ void Heap::collectIfNecessaryOrDefer(GCDeferralContext* deferralContext) ASSERT(m_maxHeapSize > m_sizeAfterLastCollect); size_t bytesAllowedThisCycle = m_maxHeapSize - m_sizeAfterLastCollect; +#if USE(BUN_JSC_ADDITIONS) + if (m_embedderMaxHeapSize || m_embedderMaxEdenSize) + bytesAllowedThisCycle = std::min(bytesAllowedThisCycle, m_maxEdenSize); +#endif bool isCritical = overCriticalMemoryThreshold(); if (isCritical) diff --git a/Source/JavaScriptCore/heap/Heap.h b/Source/JavaScriptCore/heap/Heap.h index 96dc70b6ac0c7..6a18c87a3eb53 100644 --- a/Source/JavaScriptCore/heap/Heap.h +++ b/Source/JavaScriptCore/heap/Heap.h @@ -50,6 +50,7 @@ #include #include #include +#include #include #include #include @@ -345,6 +346,7 @@ class Heap { WTF_MAKE_NONCOPYABLE(Heap); public: friend class JIT; + friend class StopIfNecessaryTimer; friend class DFG::SpeculativeJIT; static JSC::Heap* heap(const JSValue); // 0 for immediate values static JSC::Heap* heap(const HeapCell*); @@ -870,6 +872,10 @@ class Heap { typedef uint64_t Ticket; Ticket requestCollection(GCRequest); void waitForCollection(Ticket); +#if USE(BUN_JSC_ADDITIONS) + void scheduleWorkerHeapVerification(Ticket); + void finishWorkerHeapVerification(); +#endif bool suspendCompilerThreads(); void willStartCollection(); @@ -995,11 +1001,19 @@ class Heap { size_t m_maxEdenSizeWhenCritical; size_t m_maxHeapSize; #if USE(BUN_JSC_ADDITIONS) - void applyWorkerAllocationLimits(size_t currentHeapSize); size_t arrayBufferGCMemorySize() const; + void updateWorkerHeapAccounting(bool isFullCollection); + size_t remainingWorkerAllocationBudget() const; + bool workerRequiresFullCollection() const; + void applyWorkerAllocationLimits(); size_t m_embedderMaxHeapSize { 0 }; size_t m_embedderMaxEdenSize { 0 }; size_t m_sizeAfterLastFullCollectExcludingArrayBuffers { 0 }; + size_t m_workerHeapSizeUpperBound { 0 }; + CheckedSize m_workerBufferBytesAfterFullCollection { 0 }; + uint64_t m_workerAllocatedAtLastCollection { 0 }; + bool m_workerHasFullAccounting { false }; + std::atomic m_workerVerificationTicket { 0 }; bool m_heapLimitExceeded { false }; #endif size_t m_totalBytesVisitedAfterLastFullCollect { 0 }; diff --git a/Source/JavaScriptCore/heap/StopIfNecessaryTimer.cpp b/Source/JavaScriptCore/heap/StopIfNecessaryTimer.cpp index c1771d538b0d4..19d91465e1ead 100644 --- a/Source/JavaScriptCore/heap/StopIfNecessaryTimer.cpp +++ b/Source/JavaScriptCore/heap/StopIfNecessaryTimer.cpp @@ -40,9 +40,20 @@ void StopIfNecessaryTimer::doWork(VM& vm) { cancelTimer(); WTF::storeStoreFence(); +#if USE(BUN_JSC_ADDITIONS) + vm.heap.finishWorkerHeapVerification(); +#endif vm.heap.stopIfNecessary(); } +#if USE(BUN_JSC_ADDITIONS) +void StopIfNecessaryTimer::scheduleWorkerHeapVerification() +{ + // A heap-limit decision cannot depend on optional idle GC maintenance. + setTimeUntilFire(0_s); +} +#endif + void StopIfNecessaryTimer::scheduleSoon() { if (m_isDisabled) diff --git a/Source/JavaScriptCore/heap/StopIfNecessaryTimer.h b/Source/JavaScriptCore/heap/StopIfNecessaryTimer.h index 47f3a467ff83c..10c17ad1afb77 100644 --- a/Source/JavaScriptCore/heap/StopIfNecessaryTimer.h +++ b/Source/JavaScriptCore/heap/StopIfNecessaryTimer.h @@ -39,6 +39,9 @@ class StopIfNecessaryTimer final : public JSRunLoopTimer { void doWork(VM&) final; void scheduleSoon(); +#if USE(BUN_JSC_ADDITIONS) + void scheduleWorkerHeapVerification(); +#endif void NODELETE disable(); diff --git a/Source/JavaScriptCore/tools/JSDollarVM.cpp b/Source/JavaScriptCore/tools/JSDollarVM.cpp index 55387bbafae7d..925c9aa1b9910 100644 --- a/Source/JavaScriptCore/tools/JSDollarVM.cpp +++ b/Source/JavaScriptCore/tools/JSDollarVM.cpp @@ -4939,6 +4939,33 @@ JSC_DEFINE_HOST_FUNCTION(functionHeapExtraMemorySize, (JSGlobalObject* globalObj } #if USE(BUN_JSC_ADDITIONS) +JSC_DEFINE_HOST_FUNCTION(functionSetWorkerHeapLimits, (JSGlobalObject* globalObject, CallFrame* callFrame)) +{ + DollarVMAssertScope assertScope; + auto scope = DECLARE_THROW_SCOPE(globalObject->vm()); + auto oldBytes = callFrame->argument(0); + auto youngBytes = callFrame->argument(1); + if (!oldBytes.isInt32() || !youngBytes.isInt32() || oldBytes.asInt32() < 0 || youngBytes.asInt32() < 0) { + throwTypeError(globalObject, scope, "heap limits must be nonnegative int32 byte counts"_s); + return encodedJSValue(); + } + globalObject->vm().heap.setWorkerHeapLimits(oldBytes.asInt32(), youngBytes.asInt32()); + return JSValue::encode(jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(functionLastGCWasFull, (JSGlobalObject* globalObject, CallFrame*)) +{ + DollarVMAssertScope assertScope; + auto scope = globalObject->vm().heap.lastCollectionScope(); + return JSValue::encode(scope ? jsBoolean(*scope == CollectionScope::Full) : jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(functionWorkerHeapLimitExceeded, (JSGlobalObject* globalObject, CallFrame*)) +{ + DollarVMAssertScope assertScope; + return JSValue::encode(jsBoolean(globalObject->vm().heap.heapLimitExceeded())); +} + // Everything the heap counted as allocated so far, cells and reported extra memory. Collections are paced on it. JSC_DEFINE_HOST_FUNCTION(functionHeapTotalBytesAllocated, (JSGlobalObject* globalObject, CallFrame*)) { @@ -6106,6 +6133,9 @@ void JSDollarVM::finishCreation(VM& vm) addFunction(vm, alwaysAllow, "heapExtraMemorySize"_s, functionHeapExtraMemorySize, 0); #if USE(BUN_JSC_ADDITIONS) + addFunction(vm, alwaysAllow, "setWorkerHeapLimits"_s, functionSetWorkerHeapLimits, 2); + addFunction(vm, alwaysAllow, "lastGCWasFull"_s, functionLastGCWasFull, 0); + addFunction(vm, alwaysAllow, "workerHeapLimitExceeded"_s, functionWorkerHeapLimitExceeded, 0); addFunction(vm, alwaysAllow, "heapTotalBytesAllocated"_s, functionHeapTotalBytesAllocated, 0); #endif addFunction(vm, alwaysAllow, "codeBlockCensus"_s, functionCodeBlockCensus, 0);