From bf1cfd135e4acf7b1b80ad35248bb623c6f7f400 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:03:16 +0000 Subject: [PATCH 01/13] [WTF] AutomaticThread: release the thread-local heap after 100 ms idle Only the owning thread can collect its mimalloc thread-local heap. An AutomaticThread that finishes its work and waits on its condition keeps every page it freed: retired pages, blocks other threads freed into its pages, and the free runs inside pages it still uses. Nothing touches them until the thread times out after 10 s and exits, or until it works again. For the wasm compiler threads this is about 10 MB each after one compile of a 4 MB module. With numberOfWasmCompilerThreads at cores - 1, a process that compiles wasm modules holds cores x 10 MB of RSS that no live object accounts for (oven-sh/bun#41438). The JIT worklist threads hold their B3 and Air temporaries the same way. When a thread has waited 100 ms without a notify, it now calls releaseFastMallocFreeMemoryForIdleThread() with the worklist lock dropped, then waits out the rest of its timeout. A thread that is notified within 100 ms, the usual case between tasks, pays nothing. The flag resets on each unit of work. On the consumer's mimalloc (USE_EXTERNAL_MIMALLOC) the release is mi_on_thread_idle(), which also discards the free runs inside still-used pages and hands the arena purge to mimalloc's scavenger thread. The vendored mimalloc does not have that entry point, so it falls back to a forced mi_theap_collect. The jsc shell and testFFI link the archives against the vendored mimalloc-obj, so they define mi_on_thread_idle in terms of the vendored API (shell/ExternalMimallocShims.cpp). libpas and system malloc are unchanged: the hook is compiled only under USE(MIMALLOC). --- Source/JavaScriptCore/shell/CMakeLists.txt | 6 ++- .../shell/ExternalMimallocShims.cpp | 39 +++++++++++++++++++ Source/WTF/wtf/AutomaticThread.cpp | 39 +++++++++++++++++++ Source/WTF/wtf/FastMalloc.cpp | 5 +++ Source/WTF/wtf/FastMalloc.h | 2 + Source/bmalloc/CMakeLists.txt | 4 ++ Source/bmalloc/bmalloc/BPlatform.h | 7 ++++ Source/bmalloc/bmalloc/bmalloc.cpp | 16 ++++++++ Source/bmalloc/bmalloc/bmalloc.h | 5 +++ 9 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 Source/JavaScriptCore/shell/ExternalMimallocShims.cpp diff --git a/Source/JavaScriptCore/shell/CMakeLists.txt b/Source/JavaScriptCore/shell/CMakeLists.txt index b1a05d5972bf8..fe6073d65a08e 100644 --- a/Source/JavaScriptCore/shell/CMakeLists.txt +++ b/Source/JavaScriptCore/shell/CMakeLists.txt @@ -21,7 +21,10 @@ endif () if (USE_EXTERNAL_MIMALLOC) # The shipped static archives intentionally leave mi_* unresolved so the # consumer links its own mimalloc. The jsc shell still needs a working - # allocator, so link the vendored mimalloc object library here only. + # allocator, so link the vendored mimalloc object library here only, plus + # the entry points the archives expect from the consumer's mimalloc that + # the vendored one does not have (ExternalMimallocShims.cpp). + list(APPEND jsc_SOURCES ExternalMimallocShims.cpp) list(APPEND jsc_LIBRARIES $) endif () @@ -94,6 +97,7 @@ if (USE_BUN_JSC_ADDITIONS) # Same as jsc above: the shipped static archives leave mi_* unresolved # under USE_EXTERNAL_MIMALLOC, so any executable that links the # framework must supply the vendored mimalloc objects itself. + list(APPEND testFFI_SOURCES ExternalMimallocShims.cpp) list(APPEND testFFI_LIBRARIES $) endif () diff --git a/Source/JavaScriptCore/shell/ExternalMimallocShims.cpp b/Source/JavaScriptCore/shell/ExternalMimallocShims.cpp new file mode 100644 index 0000000000000..5fdc9253dde09 --- /dev/null +++ b/Source/JavaScriptCore/shell/ExternalMimallocShims.cpp @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2026 Oven, Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +// Under USE_EXTERNAL_MIMALLOC the static archives call into the consumer's mimalloc +// (oven-sh/mimalloc), which has entry points the vendored copy does not. An executable in this +// repository that links the archives against the vendored mimalloc-obj (the jsc shell, testFFI) +// supplies those entry points here, in terms of the vendored API. + +#include + +// The consumer's hook for a thread that goes idle: collect the heap, discard the free runs inside +// still-used pages, hand the arena purge to a scavenger thread. The vendored mimalloc can only do +// the first part. +extern "C" void mi_on_thread_idle(void) noexcept +{ + mi_theap_collect(mi_theap_get_default(), /* force */ true); +} diff --git a/Source/WTF/wtf/AutomaticThread.cpp b/Source/WTF/wtf/AutomaticThread.cpp index 048180f219bf3..f85c533c852fb 100644 --- a/Source/WTF/wtf/AutomaticThread.cpp +++ b/Source/WTF/wtf/AutomaticThread.cpp @@ -27,6 +27,7 @@ #include #include +#include #include #include #include @@ -37,6 +38,11 @@ WTF_MAKE_TZONE_ALLOCATED_IMPL(AutomaticThread); static constexpr bool verbose = false; +#if USE(MIMALLOC) +// How long a thread waits for more work before it releases its allocator's free memory. +static constexpr Seconds idleReleaseDelay = 100_ms; +#endif + Ref AutomaticThreadCondition::create() { return adoptRef(*new AutomaticThreadCondition); @@ -215,6 +221,11 @@ void AutomaticThread::start(const AbstractLocker&) stopImpl(locker); }; +#if USE(MIMALLOC) + // Whether this thread released its allocator's free memory since it last worked. + bool didReleaseFreeMemory = false; +#endif + for (;;) { { Locker locker { *m_lock }; @@ -228,6 +239,31 @@ void AutomaticThread::start(const AbstractLocker&) // Shut the thread down after a timeout. m_isWaiting = true; +#if USE(MIMALLOC) + // Only the owning thread can return the memory its thread-local heap holds. + // A compiler thread frees tens of MB of temporaries after a large compile, + // and without this they stay resident until the thread times out and exits. + // Wait a little first, so that a thread which is notified again right away + // (the usual case between tasks) does not pay for the release. + // + // poll() runs once per notify: JITWorklistThread counts a thread as active + // from the notify until its poll() returns Wait. So the thread stays in the + // waiting state through the release, and polls again only if a notify + // arrived meanwhile (notify() clears m_isWaiting, which is checked under the + // lock, so a notify during the release is not lost). + if (!didReleaseFreeMemory && m_timeout > idleReleaseDelay) { + m_waitCondition.waitFor(*m_lock, idleReleaseDelay); + if (!m_isWaiting) + continue; + didReleaseFreeMemory = true; + { + DropLockForScope dropLock { locker }; + releaseFastMallocFreeMemoryForIdleThread(); + } + if (!m_isWaiting) + continue; + } +#endif bool awokenByNotify = m_waitCondition.waitFor(*m_lock, m_timeout); if (verbose && !awokenByNotify && !m_isWaiting) @@ -244,6 +280,9 @@ void AutomaticThread::start(const AbstractLocker&) } } +#if USE(MIMALLOC) + didReleaseFreeMemory = false; +#endif WorkResult result = work(); if (result == WorkResult::Stop) { Locker locker { *m_lock }; diff --git a/Source/WTF/wtf/FastMalloc.cpp b/Source/WTF/wtf/FastMalloc.cpp index bb0e77712461b..3c0fe4bef0888 100644 --- a/Source/WTF/wtf/FastMalloc.cpp +++ b/Source/WTF/wtf/FastMalloc.cpp @@ -605,6 +605,11 @@ void releaseFastMallocFreeMemoryForThisThread() bmalloc::api::scavengeThisThread(); } +void releaseFastMallocFreeMemoryForIdleThread() +{ + bmalloc::api::scavengeThisThreadOnIdle(); +} + void releaseFastMallocFreeMemory() { bmalloc::api::scavenge(); diff --git a/Source/WTF/wtf/FastMalloc.h b/Source/WTF/wtf/FastMalloc.h index 0fe7df3784ae3..ae6e7548b2d22 100644 --- a/Source/WTF/wtf/FastMalloc.h +++ b/Source/WTF/wtf/FastMalloc.h @@ -177,6 +177,8 @@ WTF_EXPORT_PRIVATE size_t fastMallocGoodSize(size_t); WTF_EXPORT_PRIVATE void releaseFastMallocFreeMemory(); WTF_EXPORT_PRIVATE void releaseFastMallocFreeMemoryForThisThread(); +// For a thread that is idle and about to block for a while. See bmalloc::api::scavengeThisThreadOnIdle. +WTF_EXPORT_PRIVATE void releaseFastMallocFreeMemoryForIdleThread(); WTF_EXPORT_PRIVATE void fastCommitAlignedMemory(void*, size_t); WTF_EXPORT_PRIVATE void fastDecommitAlignedMemory(void*, size_t); diff --git a/Source/bmalloc/CMakeLists.txt b/Source/bmalloc/CMakeLists.txt index 2208481fcd570..3cd18b4ebddfe 100644 --- a/Source/bmalloc/CMakeLists.txt +++ b/Source/bmalloc/CMakeLists.txt @@ -699,6 +699,10 @@ if (USE_MIMALLOC) add_definitions(-DUSE_MIMALLOC=1) endif () +if (USE_EXTERNAL_MIMALLOC) + add_definitions(-DUSE_EXTERNAL_MIMALLOC=1) +endif () + if (USE_SYSTEM_MALLOC) add_definitions(-DUSE_SYSTEM_MALLOC=1) endif () diff --git a/Source/bmalloc/bmalloc/BPlatform.h b/Source/bmalloc/bmalloc/BPlatform.h index b59de412789ab..99a96d5e59e62 100644 --- a/Source/bmalloc/bmalloc/BPlatform.h +++ b/Source/bmalloc/bmalloc/BPlatform.h @@ -387,6 +387,13 @@ #define BUSE_MIMALLOC 0 #endif +/* The consumer links its own mimalloc (oven-sh/mimalloc), which has APIs the vendored copy does not. */ +#if BUSE(MIMALLOC) && defined(USE_EXTERNAL_MIMALLOC) && USE_EXTERNAL_MIMALLOC +#define BUSE_EXTERNAL_MIMALLOC 1 +#else +#define BUSE_EXTERNAL_MIMALLOC 0 +#endif + #if defined(USE_SYSTEM_MALLOC) && USE_SYSTEM_MALLOC #define BUSE_SYSTEM_MALLOC 1 #elif BTSAN_ENABLED diff --git a/Source/bmalloc/bmalloc/bmalloc.cpp b/Source/bmalloc/bmalloc/bmalloc.cpp index 02d1694849d29..5ac2fb569f206 100644 --- a/Source/bmalloc/bmalloc/bmalloc.cpp +++ b/Source/bmalloc/bmalloc/bmalloc.cpp @@ -125,6 +125,22 @@ void scavengeThisThread(bool force) #endif } +#if BUSE(EXTERNAL_MIMALLOC) +// oven-sh/mimalloc's idle hook: collects the thread's heap, discards the free runs inside the pages +// it still uses, and hands the arena purge to mimalloc's scavenger thread. The vendored mimalloc +// does not have it, so it is declared here and not in mimalloc.h. +extern "C" void mi_on_thread_idle(void) noexcept; +#endif + +void scavengeThisThreadOnIdle() +{ +#if BUSE(EXTERNAL_MIMALLOC) + mi_on_thread_idle(); +#else + scavengeThisThread(/* force */ true); +#endif +} + void scavenge() { #if BENABLE(LIBPAS) diff --git a/Source/bmalloc/bmalloc/bmalloc.h b/Source/bmalloc/bmalloc/bmalloc.h index 6e4a7ea21f17b..0198e8d8c5bbc 100644 --- a/Source/bmalloc/bmalloc/bmalloc.h +++ b/Source/bmalloc/bmalloc/bmalloc.h @@ -298,6 +298,11 @@ BEXPORT void freeLargeVirtual(void* object, size_t, HeapKind kind = HeapKind::Pr BEXPORT void scavengeThisThread(bool force = true); +// The calling thread is idle and about to block for a while. Return what its thread-local +// allocator state holds for it: retired pages, blocks that other threads freed into its pages, +// and the free runs inside the pages it still uses. Only the owning thread can do this. +BEXPORT void scavengeThisThreadOnIdle(); + BEXPORT void scavenge(); BEXPORT bool isEnabled(HeapKind kind = HeapKind::Primary); From f163c3b3a89cb789558a3af0355d6ee07a353af0 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sun, 4 Oct 2026 13:38:01 +0000 Subject: [PATCH 02/13] [WTF] AutomaticThread: review of the idle release - notifyOne prefers a thread that is asleep over one that is releasing its free memory, which only sees the notification when it is done with that - the 100 ms before the release count towards the timeout of the thread - the fallback for the vendored mimalloc does not force the collect: that also purges what every other thread just freed --- .../shell/ExternalMimallocShims.cpp | 2 +- Source/WTF/wtf/AutomaticThread.cpp | 18 +++++++++++++++++- Source/WTF/wtf/AutomaticThread.h | 1 + Source/bmalloc/bmalloc/bmalloc.cpp | 3 ++- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Source/JavaScriptCore/shell/ExternalMimallocShims.cpp b/Source/JavaScriptCore/shell/ExternalMimallocShims.cpp index 5fdc9253dde09..a4b74b4123b2f 100644 --- a/Source/JavaScriptCore/shell/ExternalMimallocShims.cpp +++ b/Source/JavaScriptCore/shell/ExternalMimallocShims.cpp @@ -35,5 +35,5 @@ // the first part. extern "C" void mi_on_thread_idle(void) noexcept { - mi_theap_collect(mi_theap_get_default(), /* force */ true); + mi_theap_collect(mi_theap_get_default(), /* force */ false); } diff --git a/Source/WTF/wtf/AutomaticThread.cpp b/Source/WTF/wtf/AutomaticThread.cpp index f85c533c852fb..27cc52d63b9a7 100644 --- a/Source/WTF/wtf/AutomaticThread.cpp +++ b/Source/WTF/wtf/AutomaticThread.cpp @@ -54,6 +54,16 @@ AutomaticThreadCondition::~AutomaticThreadCondition() = default; void AutomaticThreadCondition::notifyOne(const AbstractLocker& locker) { +#if USE(MIMALLOC) + // A thread that is releasing its free memory only sees the notification when it is done with that. + for (auto& thread : m_threads) { + if (thread->isWaiting(locker) && !thread->m_isReleasingFreeMemory) { + thread->notify(locker); + return; + } + } +#endif + for (auto& thread : m_threads) { if (thread->isWaiting(locker)) { thread->notify(locker); @@ -251,21 +261,27 @@ void AutomaticThread::start(const AbstractLocker&) // waiting state through the release, and polls again only if a notify // arrived meanwhile (notify() clears m_isWaiting, which is checked under the // lock, so a notify during the release is not lost). + Seconds timeout = m_timeout; if (!didReleaseFreeMemory && m_timeout > idleReleaseDelay) { m_waitCondition.waitFor(*m_lock, idleReleaseDelay); if (!m_isWaiting) continue; didReleaseFreeMemory = true; + m_isReleasingFreeMemory = true; { DropLockForScope dropLock { locker }; releaseFastMallocFreeMemoryForIdleThread(); } + m_isReleasingFreeMemory = false; if (!m_isWaiting) continue; + timeout -= idleReleaseDelay; } +#else + Seconds timeout = m_timeout; #endif bool awokenByNotify = - m_waitCondition.waitFor(*m_lock, m_timeout); + m_waitCondition.waitFor(*m_lock, timeout); if (verbose && !awokenByNotify && !m_isWaiting) dataLog(RawPointer(this), ": waitFor timed out, but notified via m_isWaiting flag!\n"); if (m_isWaiting && shouldSleep(locker)) { diff --git a/Source/WTF/wtf/AutomaticThread.h b/Source/WTF/wtf/AutomaticThread.h index f665a24883729..cda144334cff3 100644 --- a/Source/WTF/wtf/AutomaticThread.h +++ b/Source/WTF/wtf/AutomaticThread.h @@ -203,6 +203,7 @@ class WTF_EXPORT_PRIVATE AutomaticThread : public ThreadSafeRefCounted Date: Sun, 4 Oct 2026 11:57:47 +0000 Subject: [PATCH 03/13] [JSC] Atomics.wait: release the thread-local heap in a wait that takes over 100 ms --- .../runtime/WaiterListManager.cpp | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/Source/JavaScriptCore/runtime/WaiterListManager.cpp b/Source/JavaScriptCore/runtime/WaiterListManager.cpp index 3e7009b3671fc..dbeb59150bd60 100644 --- a/Source/JavaScriptCore/runtime/WaiterListManager.cpp +++ b/Source/JavaScriptCore/runtime/WaiterListManager.cpp @@ -132,8 +132,29 @@ WaiterListManager::WaitSyncResult WaiterListManager::waitSyncImpl(VM& vm, ValueT dataLogLnIf(WaiterListsManagerInternal::verbose, " added a new SyncWaiter=", syncWaiter.get(), " to a waiterList for ptr ", RawPointer(ptr)); syncWaiter->setParkedList(list.copyRef()); - while (syncWaiter->isOnList() && time.now() < time && !shouldStopWaitingForTermination(vm)) +#if USE(MIMALLOC) + // Only the owning thread can return the memory its thread-local heap holds, so do that once in a wait that + // takes a while. A wait that is notified soon pays nothing. + MonotonicTime releaseFreeMemoryTime = MonotonicTime::now() + 100_ms; + bool didReleaseFreeMemory = false; +#endif + while (syncWaiter->isOnList() && time.now() < time && !shouldStopWaitingForTermination(vm)) { +#if USE(MIMALLOC) + if (!didReleaseFreeMemory && releaseFreeMemoryTime < time) { + if (MonotonicTime::now() < releaseFreeMemoryTime) { + syncWaiter->condition().waitUntil(list->lock, releaseFreeMemoryTime.approximate()); + continue; + } + didReleaseFreeMemory = true; + // A notification in the meantime takes us off the list, and the loop tests for that (and for a + // termination request) under the lock again before it waits. + DropLockForScope dropLock { listLocker }; + WTF::releaseFastMallocFreeMemoryForIdleThread(); + continue; + } +#endif syncWaiter->condition().waitUntil(list->lock, time.approximate()); + } syncWaiter->setParkedList(nullptr); From aa631fd8d6a48a092d4fa8089ff1ff5b532353bc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 4 Oct 2026 09:52:34 -0700 Subject: [PATCH 04/13] fix(intl): preserve segment boundaries around surrogate pairs Port oven-sh/WebKit#753 (3a983c8140415ea792dfebfcef68c6c9e65f9f1b), authored by Peter Steinberger (@steipete). Provenance: native W113 port 22908502aae3dea32fb377bc65cb15ecebfc4e0c, based on 1600131e46b5af48bbda3559af8d8a3327230b6e. Restore the end boundary before reading word status; preserve the regression corpus. --- JSTests/stress/intl-segmenter.js | 32 +++++++++++++++++++ .../JavaScriptCore/runtime/IntlSegments.cpp | 12 +++---- jsc.md | 4 +++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/JSTests/stress/intl-segmenter.js b/JSTests/stress/intl-segmenter.js index ac3d98ccdaa49..55c0cc7d63fef 100644 --- a/JSTests/stress/intl-segmenter.js +++ b/JSTests/stress/intl-segmenter.js @@ -330,3 +330,35 @@ function shouldNotThrow(func) { shouldBe(" ", segment); shouldBe(false, isWordLike); } + +// containing() must agree with iteration at both halves of surrogate pairs. +{ + const inputs = [ + "a๐Ÿ˜€b", + "Hello, world! ๐Ÿ‘๐Ÿฝ x", + "a๐Ÿ‡ฏ๐Ÿ‡ตb", + "a๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆb", + "Hi. ๐Ÿ‘ Bye.", + "๐Ÿ‘๐Ÿฝ", + "x\ud83d", + "abc", + ]; + for (const granularity of ["grapheme", "word", "sentence"]) { + for (const input of inputs) { + const segments = new Intl.Segmenter("en", { granularity }).segment(input); + const expected = Array.from(segments); + for (const direction of [1, -1]) { + for (let index = direction > 0 ? 0 : input.length - 1; index >= 0 && index < input.length; index += direction) { + const result = expected.find(segment => segment.index <= index && index < segment.index + segment.segment.length); + const actual = segments.containing(index); + shouldBe(actual.segment, result.segment); + shouldBe(actual.index, result.index); + shouldBe(actual.input, input); + shouldBe(actual.isWordLike, result.isWordLike); + } + } + shouldBe(segments.containing(-1), undefined); + shouldBe(segments.containing(input.length), undefined); + } + } +} diff --git a/Source/JavaScriptCore/runtime/IntlSegments.cpp b/Source/JavaScriptCore/runtime/IntlSegments.cpp index ec049f6c4aeba..eb6132bc2b24d 100644 --- a/Source/JavaScriptCore/runtime/IntlSegments.cpp +++ b/Source/JavaScriptCore/runtime/IntlSegments.cpp @@ -71,15 +71,15 @@ JSValue IntlSegments::containing(JSGlobalObject* globalObject, JSValue indexValu return jsUndefined(); int32_t index = toInt32(value); - // The result of ubrk_preceding is always *smaller* than offset, or UBRK_DONE. In this case, we should set scan position with `index + 1`. - // Even if index + 1 exceeds length of string by 1, this is desirable if we want to scan the last segment. - int32_t startIndex = ubrk_preceding(m_segmenter.get(), index + 1); - if (startIndex == UBRK_DONE) - startIndex = 0; - // The result of ubrk_following is always greater than offset, or UBRK_DONE. Scan position should be `index`. + // Find the end first: index + 1 may be a trail surrogate that ICU moves backward. int32_t endIndex = ubrk_following(m_segmenter.get(), index); if (endIndex == UBRK_DONE) endIndex = m_buffer->size(); + int32_t startIndex = ubrk_previous(m_segmenter.get()); + if (startIndex == UBRK_DONE) + startIndex = 0; + // Segment data uses the rule status at the end boundary for isWordLike. + ubrk_next(m_segmenter.get()); scope.release(); return createSegmentDataObject(globalObject, m_string.get(), startIndex, endIndex, *m_segmenter, m_granularity); diff --git a/jsc.md b/jsc.md index 1009e0efa16e3..aef529dec8584 100644 --- a/jsc.md +++ b/jsc.md @@ -1,5 +1,9 @@ # JavaScriptCore Changelog +## Unreleased + +- Fix `Intl.Segmenter` containing lookups around surrogate pairs so segment boundaries and word status agree with iteration. + This changelog covers updates to JavaScriptCore in Bun's WebKit fork, from `9a2cc42ae1bf` to `7bc2f97e2835`. ## Performance From 297efd07d765336410671c36218dfe233dee5540 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 4 Oct 2026 09:52:35 -0700 Subject: [PATCH 05/13] fix(jsc): allow opt-in Proxy global prototypes Port oven-sh/WebKit#626 (f1e23f9731fc3899f50ae6c91d4c4242d237d0ca), authored by robobun (@robobun). Provenance: native W113 port 6ecc6245dcab7418d05441535815a6388c82177d. Keep the default unchanged. The embedding Bun runtime must opt in and rebuild against the matching engine layout. --- Source/JavaScriptCore/runtime/JSGlobalObject.h | 13 +++++++++++++ .../runtime/ProgramExecutable.cpp | 17 ++++++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/Source/JavaScriptCore/runtime/JSGlobalObject.h b/Source/JavaScriptCore/runtime/JSGlobalObject.h index f8d47bd27003e..e71690feaea83 100644 --- a/Source/JavaScriptCore/runtime/JSGlobalObject.h +++ b/Source/JavaScriptCore/runtime/JSGlobalObject.h @@ -699,6 +699,9 @@ class JSGlobalObject : public JSSegmentedVariableObject { bool m_webAssemblyEnabled { true }; bool m_needsSiteSpecificQuirks { false }; bool m_canDoASCIIUCADUCETLocaleCompare { false }; +#if USE(BUN_JSC_ADDITIONS) + bool m_allowsProxyInPrototypeChain { false }; +#endif unsigned m_globalLexicalBindingEpoch { 1 }; String m_evalDisabledErrorMessage; String m_webAssemblyDisabledErrorMessage; @@ -1361,6 +1364,16 @@ class JSGlobalObject : public JSSegmentedVariableObject { bool needsSiteSpecificQuirks() const { return m_needsSiteSpecificQuirks; } JS_EXPORT_PRIVATE void exposeDollarVM(VM&); +#if USE(BUN_JSC_ADDITIONS) + // A global object is normally an immutable prototype exotic object, so a Proxy can never reach its + // prototype chain, and ProgramExecutable::initializeGlobalProperties rejects a chain that holds one. + // An embedder whose global object has a mutable prototype (node:vm contexts, where jsdom installs a + // Proxy in the chain of the window) opts out of that rejection here. Global declaration instantiation + // only reads own properties of the global object, so no Proxy trap runs while a program links. + bool allowsProxyInPrototypeChain() const { return m_allowsProxyInPrototypeChain; } + void setAllowsProxyInPrototypeChain(bool allows) { m_allowsProxyInPrototypeChain = allows; } +#endif + #if JSC_OBJC_API_ENABLED JSWrapperMap* wrapperMap() const { return m_wrapperMap.get(); } void setWrapperMap(JSWrapperMap* map) { m_wrapperMap = map; } diff --git a/Source/JavaScriptCore/runtime/ProgramExecutable.cpp b/Source/JavaScriptCore/runtime/ProgramExecutable.cpp index 910faef6ef80c..e6821d0d75d9f 100644 --- a/Source/JavaScriptCore/runtime/ProgramExecutable.cpp +++ b/Source/JavaScriptCore/runtime/ProgramExecutable.cpp @@ -117,11 +117,18 @@ JSObject* ProgramExecutable::initializeGlobalProperties(VM& vm, JSGlobalObject* if (error.isValid()) RELEASE_AND_RETURN(throwScope, error.toErrorObject(globalObject, source())); - JSValue nextPrototype = globalObject->getPrototypeDirect(); - while (nextPrototype && nextPrototype.isObject()) { - if (asObject(nextPrototype)->type() == ProxyObjectType) [[unlikely]] - return createTypeError(globalObject, "Proxy is not allowed in the global prototype chain."_s); - nextPrototype = asObject(nextPrototype)->getPrototypeDirect(); +#if USE(BUN_JSC_ADDITIONS) + bool rejectProxyInPrototypeChain = !globalObject->allowsProxyInPrototypeChain(); +#else + bool rejectProxyInPrototypeChain = true; +#endif + if (rejectProxyInPrototypeChain) { + JSValue nextPrototype = globalObject->getPrototypeDirect(); + while (nextPrototype && nextPrototype.isObject()) { + if (asObject(nextPrototype)->type() == ProxyObjectType) [[unlikely]] + return createTypeError(globalObject, "Proxy is not allowed in the global prototype chain."_s); + nextPrototype = asObject(nextPrototype)->getPrototypeDirect(); + } } JSGlobalLexicalEnvironment* globalLexicalEnvironment = globalObject->globalLexicalEnvironment(); From fcda75076bdcffeef3973cdf1376cfb9cd22048e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 4 Oct 2026 09:52:36 -0700 Subject: [PATCH 06/13] fix(jsc): preserve module-loader async context Integrate native W137 patch 7f54367f20a026648fcfc8d3790d303e1d656056 by Peter Steinberger. Related upstream work: oven-sh/WebKit#274 and oven-sh/bun#37933 by robobun (@robobun). Capture the registration context and restore it for full loader reaction dispatch, preserving the result slot and disambiguating deferred-import tuples. The input patch passed 14 loader variants, retention and continuous-GC proof. --- Source/JavaScriptCore/runtime/JSMicrotask.cpp | 18 +++++++++++++++++- Source/JavaScriptCore/runtime/JSPromise.cpp | 9 +++++++++ Source/JavaScriptCore/runtime/Microtask.h | 8 ++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/Source/JavaScriptCore/runtime/JSMicrotask.cpp b/Source/JavaScriptCore/runtime/JSMicrotask.cpp index 25281addf0bd5..63626301cb737 100644 --- a/Source/JavaScriptCore/runtime/JSMicrotask.cpp +++ b/Source/JavaScriptCore/runtime/JSMicrotask.cpp @@ -1804,7 +1804,7 @@ static void asyncGeneratorDriverResume(VM& vm, JSValue context, JSValue resoluti asyncModuleExecutionResume(module->realm(), vm, module, resolution, status); } -void runInternalMicrotask(JSGlobalObject* globalObject, VM& vm, InternalMicrotask task, uint8_t payload, std::span arguments, MicrotaskCallCache* microtaskCallCache) +static void runInternalMicrotaskImpl(JSGlobalObject* globalObject, VM& vm, InternalMicrotask task, uint8_t payload, std::span arguments, MicrotaskCallCache* microtaskCallCache) { auto scope = DECLARE_THROW_SCOPE(vm); @@ -2355,6 +2355,22 @@ void runInternalMicrotask(JSGlobalObject* globalObject, VM& vm, InternalMicrotas } } +void runInternalMicrotask(JSGlobalObject* globalObject, VM& vm, InternalMicrotask task, uint8_t payload, std::span arguments, MicrotaskCallCache* microtaskCallCache) +{ +#if USE(BUN_JSC_ADDITIONS) + if (capturesModuleLoaderAsyncContext(task) + && (vm.isAsyncContextTrackingEnabled() || AsyncContextSwapScope::isContextTuple(arguments[2]))) { + JSValue context = arguments[2]; + JSValue asyncContext = AsyncContextSwapScope::unwrapContextTuple(context); + AsyncContextSwapScope asyncContextScope(vm, globalObject, asyncContext); + const std::array unwrapped { arguments[0], arguments[1], context, arguments[3] }; + runInternalMicrotaskImpl(globalObject, vm, task, payload, unwrapped, microtaskCallCache); + return; + } +#endif + runInternalMicrotaskImpl(globalObject, vm, task, payload, arguments, microtaskCallCache); +} + } // namespace JSC WTF_ALLOW_UNSAFE_BUFFER_USAGE_END diff --git a/Source/JavaScriptCore/runtime/JSPromise.cpp b/Source/JavaScriptCore/runtime/JSPromise.cpp index 434d4fba13102..78dd6c5a7d4ec 100644 --- a/Source/JavaScriptCore/runtime/JSPromise.cpp +++ b/Source/JavaScriptCore/runtime/JSPromise.cpp @@ -513,6 +513,15 @@ void JSPromise::performPromiseThenWithContext(VM& vm, JSGlobalObject* globalObje #if USE(BUN_JSC_ADDITIONS) void JSPromise::performPromiseThenWithInternalMicrotask(VM& vm, InternalMicrotask task, JSCell* cell, JSValue context, JSValue asyncContext) { + // Loader reactions need both their result cell and their async context. + // Keep the result slot intact; the existing visited tuple owns the snapshot. + if (capturesModuleLoaderAsyncContext(task)) { + auto* globalObject = realm(); + JSValue loaderAsyncContext = AsyncContextSwapScope::current(vm, globalObject); + // import.defer() has an InternalFieldTuple payload even without ALS. + if (!loaderAsyncContext.isUndefined() || AsyncContextSwapScope::isContextTuple(context)) + context = InternalFieldTuple::create(vm, globalObject->internalFieldTupleStructure(), context, loaderAsyncContext); + } // Only the tasks that take no cell argument (the await family) capture an // async context; it travels in the slots the cell would use. bool hasAsyncContext = !asyncContext.isEmpty() && !asyncContext.isUndefined(); diff --git a/Source/JavaScriptCore/runtime/Microtask.h b/Source/JavaScriptCore/runtime/Microtask.h index 47e84db4dc344..3a01f503267ec 100644 --- a/Source/JavaScriptCore/runtime/Microtask.h +++ b/Source/JavaScriptCore/runtime/Microtask.h @@ -97,6 +97,14 @@ enum class InternalMicrotask : uint8_t { #if USE(BUN_JSC_ADDITIONS) constexpr unsigned maxMicrotaskArguments = 4; +// These reactions use argument 2 for their loader payload. Await resumptions +// already carry their context separately and must not be wrapped a second time. +constexpr bool capturesModuleLoaderAsyncContext(InternalMicrotask task) +{ + return task >= InternalMicrotask::AsyncModuleExecutionDone + && task <= InternalMicrotask::ImportModuleNamespace; +} + // OR'ed into the payload (a JSPromise::Status) of a PromiseReactionJob whose // fourth argument is the async context captured by performPromiseThen (see // AsyncContextSwapScope) rather than an embedder handler context. From 602011ef916a0b756d628e2f1ab2344f3e7a2594 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 4 Oct 2026 09:52:37 -0700 Subject: [PATCH 07/13] feat(jsc): add per-VM worker heap and stack budgets Integrate native W120 heap patch 70ac649212825168704d1ba8297a74397732bbbc and stack patch 013095fce722cd26795e15760b40562003ecc38c by Peter Steinberger. The matching Bun adapter builds on oven-sh/bun#32896 by robobun (@robobun). Measure the managed live set after full collection and exclude ArrayBuffer backing storage. The embedder owns worker-only termination. Process defaults stay unchanged. Native input proof passed seven engine counters, fourteen worker regressions and both custody cases. --- .../JavaScriptCore/heap/AbstractSlotVisitor.h | 3 ++ Source/JavaScriptCore/heap/Heap.cpp | 46 ++++++++++++++++++- Source/JavaScriptCore/heap/Heap.h | 16 +++++++ Source/JavaScriptCore/heap/SlotVisitor.h | 3 ++ .../JavaScriptCore/heap/SlotVisitorInlines.h | 8 ++++ .../JavaScriptCore/heap/VerifierSlotVisitor.h | 3 ++ .../runtime/JSGenericTypedArrayViewInlines.h | 6 +++ Source/JavaScriptCore/runtime/VM.cpp | 17 ++++++- Source/JavaScriptCore/runtime/VM.h | 6 +++ 9 files changed, 105 insertions(+), 3 deletions(-) diff --git a/Source/JavaScriptCore/heap/AbstractSlotVisitor.h b/Source/JavaScriptCore/heap/AbstractSlotVisitor.h index b3db3c386f16b..adb0a08984d4c 100644 --- a/Source/JavaScriptCore/heap/AbstractSlotVisitor.h +++ b/Source/JavaScriptCore/heap/AbstractSlotVisitor.h @@ -192,6 +192,9 @@ class AbstractSlotVisitor { virtual void markAuxiliary(const void* base) = 0; virtual void reportExtraMemoryVisited(size_t) = 0; +#if USE(BUN_JSC_ADDITIONS) + virtual void reportWorkerTypedArrayBytesVisited(size_t) = 0; +#endif #if ENABLE(RESOURCE_USAGE) virtual void reportExternalMemoryVisited(size_t) = 0; #endif diff --git a/Source/JavaScriptCore/heap/Heap.cpp b/Source/JavaScriptCore/heap/Heap.cpp index a39cf9a107123..73f190eab6bc1 100644 --- a/Source/JavaScriptCore/heap/Heap.cpp +++ b/Source/JavaScriptCore/heap/Heap.cpp @@ -3031,6 +3031,10 @@ void Heap::willStartCollection() m_sizeBeforeLastFullCollect = m_sizeAfterLastCollect + totalBytesAllocatedThisCycle(); m_extraMemorySize = 0; m_deprecatedExtraMemorySize = 0; +#if USE(BUN_JSC_ADDITIONS) + if (m_embedderMaxHeapSize) + m_workerTypedArrayBytesVisited.store(0); +#endif #if ENABLE(RESOURCE_USAGE) m_externalMemorySize = 0; #endif @@ -3101,6 +3105,34 @@ void Heap::deleteSourceProviderCaches() vm().clearSourceProviderCaches(); } +#if USE(BUN_JSC_ADDITIONS) +void Heap::setWorkerHeapLimits(size_t heapBytes, size_t edenBytes) +{ + m_embedderMaxHeapSize = heapBytes; + m_embedderMaxEdenSize = edenBytes; + applyWorkerAllocationLimits(m_sizeAfterLastCollect); +} + +void Heap::applyWorkerAllocationLimits(size_t currentHeapSize) +{ + 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 (m_embedderMaxHeapSize || m_embedderMaxEdenSize) { + CheckedSize nextLimit = currentHeapSize; + nextLimit += std::max(1, m_maxEdenSize); + if (!nextLimit.hasOverflowed()) + m_maxHeapSize = std::min(m_maxHeapSize, nextLimit.value()); + } +} +#endif + void Heap::updateAllocationLimits() { constexpr bool verbose = false; @@ -3179,6 +3211,14 @@ void Heap::updateAllocationLimits() m_sizeAfterLastCollect = currentHeapSize; #if USE(BUN_JSC_ADDITIONS) + if (m_embedderMaxHeapSize && m_collectionScope == CollectionScope::Full) { + // Count vectors during tracing, including allocations emitted inline by the JIT. + size_t buffers = arrayBufferSize() + m_workerTypedArrayBytesVisited.load(); + m_sizeAfterLastFullCollectExcludingArrayBuffers = currentHeapSize > buffers ? currentHeapSize - buffers : 0; + m_heapLimitExceeded = m_embedderMaxHeapSize && m_sizeAfterLastFullCollectExcludingArrayBuffers > m_embedderMaxHeapSize; + } + if (m_embedderMaxHeapSize || m_embedderMaxEdenSize) + applyWorkerAllocationLimits(currentHeapSize); if (std::exchange(m_reenableEdenActivityCallback, false) && m_edenActivityCallback) m_edenActivityCallback->setEnabled(true); if (std::exchange(m_reenableFullActivityCallback, false) && m_fullActivityCallback) @@ -3589,7 +3629,11 @@ void Heap::collectIfNecessaryOrDefer(GCDeferralContext* deferralContext) // that object is still live and doesn't give us much indication about how much memory we could actually reclaim. That said, // if the system is cricital or we have a small heap we want to be very agressive about reclaiming memory to reduce overall // pressure on the system. - if (!isCritical && m_heapType == HeapType::Large) { + if (!isCritical && m_heapType == HeapType::Large +#if USE(BUN_JSC_ADDITIONS) + && !m_embedderMaxHeapSize +#endif + ) { if (static_cast(m_lastOversidedAllocationThisCycle) / bytesAllocatedThisCycle > 1.0 / 3.0) return false; } diff --git a/Source/JavaScriptCore/heap/Heap.h b/Source/JavaScriptCore/heap/Heap.h index 13baff838bdb1..81180f182d81c 100644 --- a/Source/JavaScriptCore/heap/Heap.h +++ b/Source/JavaScriptCore/heap/Heap.h @@ -525,6 +525,14 @@ class Heap { size_t sizeAfterLastEdenCollection() const { return m_sizeAfterLastEdenCollect; } size_t sizeBeforeLastFullCollection() const { return m_sizeBeforeLastFullCollect; } size_t sizeAfterLastFullCollection() const { return m_sizeAfterLastFullCollect; } +#if USE(BUN_JSC_ADDITIONS) + // The embedder must terminate only its owning VM when this limit is exceeded. + JS_EXPORT_PRIVATE void setWorkerHeapLimits(size_t heapBytes, size_t edenBytes); + bool heapLimitExceeded() const { return m_heapLimitExceeded; } + bool hasEmbedderHeapLimit() const { return m_embedderMaxHeapSize; } + void reportWorkerTypedArrayBytesVisited(size_t bytes) { m_workerTypedArrayBytesVisited.exchangeAdd(bytes); } + size_t sizeAfterLastFullCollectionExcludingArrayBuffers() const { return m_sizeAfterLastFullCollectExcludingArrayBuffers; } +#endif void deleteAllCodeBlocks(DeleteAllCodeEffort, bool keepWhatNeedsParsing = false); void deleteAllUnlinkedCodeBlocks(DeleteAllCodeEffort, OptionSet = UnlinkedCodeToDelete::Generated); @@ -960,6 +968,14 @@ class Heap { size_t m_maxEdenSize; size_t m_maxEdenSizeWhenCritical; size_t m_maxHeapSize; +#if USE(BUN_JSC_ADDITIONS) + void applyWorkerAllocationLimits(size_t currentHeapSize); + size_t m_embedderMaxHeapSize { 0 }; + Atomic m_workerTypedArrayBytesVisited { 0 }; + size_t m_embedderMaxEdenSize { 0 }; + size_t m_sizeAfterLastFullCollectExcludingArrayBuffers { 0 }; + bool m_heapLimitExceeded { false }; +#endif size_t m_totalBytesVisitedAfterLastFullCollect { 0 }; size_t m_totalBytesVisited { 0 }; size_t m_totalBytesVisitedThisCycle { 0 }; diff --git a/Source/JavaScriptCore/heap/SlotVisitor.h b/Source/JavaScriptCore/heap/SlotVisitor.h index 0fe312513e43a..428109ca0b475 100644 --- a/Source/JavaScriptCore/heap/SlotVisitor.h +++ b/Source/JavaScriptCore/heap/SlotVisitor.h @@ -153,6 +153,9 @@ class SlotVisitor final : public AbstractSlotVisitor { void markAuxiliary(const void* base) final; void reportExtraMemoryVisited(size_t) final; +#if USE(BUN_JSC_ADDITIONS) + void reportWorkerTypedArrayBytesVisited(size_t) final; +#endif #if ENABLE(RESOURCE_USAGE) void reportExternalMemoryVisited(size_t) final; #endif diff --git a/Source/JavaScriptCore/heap/SlotVisitorInlines.h b/Source/JavaScriptCore/heap/SlotVisitorInlines.h index 041d81f2b6eb4..45b1d40bf5ff2 100644 --- a/Source/JavaScriptCore/heap/SlotVisitorInlines.h +++ b/Source/JavaScriptCore/heap/SlotVisitorInlines.h @@ -192,6 +192,14 @@ inline void SlotVisitor::reportExternalMemoryVisited(size_t size) } #endif +#if USE(BUN_JSC_ADDITIONS) +inline void SlotVisitor::reportWorkerTypedArrayBytesVisited(size_t bytes) +{ + if (m_isFirstVisit && heap()->hasEmbedderHeapLimit() && heap()->collectionScope() == CollectionScope::Full) + heap()->reportWorkerTypedArrayBytesVisited(bytes); +} +#endif + template IterationStatus SlotVisitor::forEachMarkStack(const Func& func) { diff --git a/Source/JavaScriptCore/heap/VerifierSlotVisitor.h b/Source/JavaScriptCore/heap/VerifierSlotVisitor.h index 28e1644ea977f..fbc895af6cf9c 100644 --- a/Source/JavaScriptCore/heap/VerifierSlotVisitor.h +++ b/Source/JavaScriptCore/heap/VerifierSlotVisitor.h @@ -89,6 +89,9 @@ class VerifierSlotVisitor : public AbstractSlotVisitor { void markAuxiliary(const void*) final; void reportExtraMemoryVisited(size_t) final { } +#if USE(BUN_JSC_ADDITIONS) + void reportWorkerTypedArrayBytesVisited(size_t) final { } +#endif #if ENABLE(RESOURCE_USAGE) void reportExternalMemoryVisited(size_t) final { } #endif diff --git a/Source/JavaScriptCore/runtime/JSGenericTypedArrayViewInlines.h b/Source/JavaScriptCore/runtime/JSGenericTypedArrayViewInlines.h index 4fe497eca23c4..b225ddd5b26bb 100644 --- a/Source/JavaScriptCore/runtime/JSGenericTypedArrayViewInlines.h +++ b/Source/JavaScriptCore/runtime/JSGenericTypedArrayViewInlines.h @@ -774,11 +774,17 @@ void JSGenericTypedArrayView::visitChildrenImpl(JSCell* cell, Visitor& case FastTypedArray: { if (vector) visitor.markAuxiliary(vector); +#if USE(BUN_JSC_ADDITIONS) + visitor.reportWorkerTypedArrayBytesVisited(byteSize); +#endif break; } case OversizeTypedArray: { visitor.reportExtraMemoryVisited(byteSize); +#if USE(BUN_JSC_ADDITIONS) + visitor.reportWorkerTypedArrayBytesVisited(byteSize); +#endif break; } diff --git a/Source/JavaScriptCore/runtime/VM.cpp b/Source/JavaScriptCore/runtime/VM.cpp index dbc794e2c3042..7eaf107c16da1 100644 --- a/Source/JavaScriptCore/runtime/VM.cpp +++ b/Source/JavaScriptCore/runtime/VM.cpp @@ -1399,6 +1399,14 @@ static void preCommitStackMemory(void* stackLimit) } #endif +#if USE(BUN_JSC_ADDITIONS) +void VM::setWorkerStackUsage(size_t bytes) +{ + m_workerStackUsage = bytes; + updateStackLimits(); +} +#endif + void VM::updateStackLimits() { void* lastSoftStackLimit = traps().softStackLimit(); @@ -1414,8 +1422,13 @@ void VM::updateStackLimits() void* newSoftStackLimit = 0; if (m_stackPointerAtVMEntry) { char* startOfStack = reinterpret_cast(m_stackPointerAtVMEntry); - newSoftStackLimit = stack.recursionLimit(startOfStack, Options::maxPerThreadStackUsage(), m_currentSoftReservedZoneSize); - m_stackLimit = stack.recursionLimit(startOfStack, Options::maxPerThreadStackUsage(), reservedZoneSize); + size_t maxStackUsage = Options::maxPerThreadStackUsage(); +#if USE(BUN_JSC_ADDITIONS) + if (m_workerStackUsage) + maxStackUsage = m_workerStackUsage; +#endif + newSoftStackLimit = stack.recursionLimit(startOfStack, maxStackUsage, m_currentSoftReservedZoneSize); + m_stackLimit = stack.recursionLimit(startOfStack, maxStackUsage, reservedZoneSize); } else { newSoftStackLimit = stack.recursionLimit(m_currentSoftReservedZoneSize); m_stackLimit = stack.recursionLimit(reservedZoneSize); diff --git a/Source/JavaScriptCore/runtime/VM.h b/Source/JavaScriptCore/runtime/VM.h index dfdfb89fe4bf8..4727cbead7478 100644 --- a/Source/JavaScriptCore/runtime/VM.h +++ b/Source/JavaScriptCore/runtime/VM.h @@ -888,6 +888,9 @@ class VM : public ThreadSafeRefCountedWithSuppressingSaferCPPChecking { void* stackPointerAtVMEntry() const { return m_stackPointerAtVMEntry; } void setStackPointerAtVMEntry(void*); +#if USE(BUN_JSC_ADDITIONS) + JS_EXPORT_PRIVATE void setWorkerStackUsage(size_t bytes); +#endif size_t softReservedZoneSize() const { return m_currentSoftReservedZoneSize; } size_t updateSoftReservedZoneSize(size_t softReservedZoneSize); @@ -1385,6 +1388,9 @@ class VM : public ThreadSafeRefCountedWithSuppressingSaferCPPChecking { void* m_stackPointerAtVMEntry { nullptr }; size_t m_currentSoftReservedZoneSize; void* m_stackLimit { nullptr }; +#if USE(BUN_JSC_ADDITIONS) + size_t m_workerStackUsage { 0 }; +#endif void* m_lastStackTop { nullptr }; #if ENABLE(EXCEPTION_SCOPE_VERIFICATION) From 6ec8002ae1e3388a5ec7984608bbac4063ef2e66 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 4 Oct 2026 10:14:36 -0700 Subject: [PATCH 08/13] ci: add immutable OpenClaw WebKit artifact pipeline Build the nine variants consumed by the OpenClaw Bun workflows with unchanged upstream recipes. Gate publication on matched native Linux regression proof, checksum verification, protected manual authorization and immutable GitHub releases. Remove inherited publishing workflows and document patching and rollback. --- .github/actionlint.yaml | 3 + .github/openclaw/artifacts.py | 120 ++++ .github/openclaw/preflight.sh | 18 + .../openclaw/qualification/compare-fork-ci.py | 32 + .../openclaw/qualification/module-context.mjs | 186 +++++ .../qualification/patches/000-checksums.patch | 282 ++++++++ .../qualification/patches/001-proxy.patch | 84 +++ .../003-bun-worker-resource-limits.patch | 674 ++++++++++++++++++ .../patches/004-bun-limit-readiness.patch | 77 ++ .../005-bun-limit-initialization-gc.patch | 35 + .../006-bun-node-worker-boundary.patch | 149 ++++ .../007-bun-node-getter-semantics.patch | 92 +++ .github/openclaw/qualification/run-fork-ci.sh | 49 ++ .github/openclaw/qualification/segmenter.js | 32 + .github/openclaw/qualification/selected.json | 46 ++ .../qualification/stage-local-artifact.py | 26 + .../qualification/upstream-artifacts.json | 173 +++++ .../worker-resource-limits.test.ts | 121 ++++ .github/openclaw/qualify.sh | 84 +++ .github/openclaw/release.py | 141 ++++ .github/openclaw/test_release.py | 88 +++ .github/openclaw/variants.json | 17 + .github/workflows/ci.yml | 549 -------------- .github/workflows/mirror-llvm-debs.yml | 31 - .github/workflows/openclaw-artifacts.yml | 192 +++++ OPENCLAW.md | 92 +++ 26 files changed, 2813 insertions(+), 580 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100644 .github/openclaw/artifacts.py create mode 100644 .github/openclaw/preflight.sh create mode 100644 .github/openclaw/qualification/compare-fork-ci.py create mode 100644 .github/openclaw/qualification/module-context.mjs create mode 100644 .github/openclaw/qualification/patches/000-checksums.patch create mode 100644 .github/openclaw/qualification/patches/001-proxy.patch create mode 100644 .github/openclaw/qualification/patches/003-bun-worker-resource-limits.patch create mode 100644 .github/openclaw/qualification/patches/004-bun-limit-readiness.patch create mode 100644 .github/openclaw/qualification/patches/005-bun-limit-initialization-gc.patch create mode 100644 .github/openclaw/qualification/patches/006-bun-node-worker-boundary.patch create mode 100644 .github/openclaw/qualification/patches/007-bun-node-getter-semantics.patch create mode 100644 .github/openclaw/qualification/run-fork-ci.sh create mode 100644 .github/openclaw/qualification/segmenter.js create mode 100644 .github/openclaw/qualification/selected.json create mode 100644 .github/openclaw/qualification/stage-local-artifact.py create mode 100644 .github/openclaw/qualification/upstream-artifacts.json create mode 100644 .github/openclaw/qualification/worker-resource-limits.test.ts create mode 100644 .github/openclaw/qualify.sh create mode 100644 .github/openclaw/release.py create mode 100644 .github/openclaw/test_release.py create mode 100644 .github/openclaw/variants.json delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/mirror-llvm-debs.yml create mode 100644 .github/workflows/openclaw-artifacts.yml create mode 100644 OPENCLAW.md diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000000000..0a88ea9c6fd4e --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,3 @@ +self-hosted-runner: + labels: + - blacksmith-32vcpu-ubuntu-2404 diff --git a/.github/openclaw/artifacts.py b/.github/openclaw/artifacts.py new file mode 100644 index 0000000000000..b7c2f3640fcec --- /dev/null +++ b/.github/openclaw/artifacts.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Owned packaging and provenance around the unchanged upstream lane runner.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import time + +BASE = '1600131e46b5af48bbda3559af8d8a3327230b6e' +ROOT = Path(__file__).resolve().parents[2] +CONFIG = json.loads((ROOT / '.github/openclaw/variants.json').read_text()) +RECIPE = ['.github/scripts/lanes.mjs', 'Dockerfile', 'Dockerfile.musl', 'Dockerfile.macos', 'Dockerfile.windows', 'icu', 'macos-cross'] + +def run(*args, **kwargs): + return subprocess.run(args, check=True, text=True, **kwargs) + +def capture(*args): + return subprocess.check_output(args, text=True).strip() + +def digest(path): + with path.open('rb') as f: + return hashlib.file_digest(f, 'sha256').hexdigest() + +def write(path, value): + path.write_text(json.dumps(value, indent=2) + '\n') + +def source(): + sha = capture('git', 'rev-parse', 'HEAD') + if not re.fullmatch('[0-9a-f]{40}', sha): + raise ValueError('full source SHA required') + run('git', 'merge-base', '--is-ancestor', BASE, sha) + run('git', 'diff', '--exit-code', BASE, sha, '--', *RECIPE) + run('git', 'diff', '--exit-code', 'HEAD') + return sha + +def lane_definitions(): + return json.loads(capture('node', '.github/scripts/lanes.mjs', '--json')) + +def preflight(): + sha = source() + if sha != os.environ['INPUT_SHA'] or sha != os.environ['GITHUB_SHA']: + raise ValueError('dispatch must use the same reviewed branch head and full input SHA') + if os.environ['GITHUB_REF'] != 'refs/heads/openclaw/main': + raise ValueError('only openclaw/main may run this workflow') + upstream = {l['label'] for l in lane_definitions()} + wanted = [l['label'] for l in CONFIG['lanes']] + if len(set(wanted)) != len(wanted) or not set(wanted) <= upstream: + raise ValueError('invalid or duplicate lane selection') + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write('matrix=' + json.dumps({'include': [{'lane': l} for l in wanted]}, separators=(',', ':')) + '\n') + +def build(label, destination): + sha = source() + if label not in [l['label'] for l in CONFIG['lanes']]: + raise ValueError('lane is outside the approved consumer matrix') + lane = next(l for l in lane_definitions() if l['label'] == label) + destination = destination.resolve() + destination.mkdir(parents=True, exist_ok=False) + artifact = destination / 'bun-webkit' + provenance = destination / 'provenance' + provenance.mkdir() + command = json.loads(capture('node', '.github/scripts/lanes.mjs', 'build', label, '--output', str(artifact), '--dry-run')) + # Build the unchanged base locally. No image registry write or inherited workflow is used. + base_tag = 'openclaw-webkit-toolchain:' + hashlib.sha256(lane['image'].encode()).hexdigest()[:24] + base_command = command[:command.index('--target=artifact')] + ['--target=base', '--load', '--tag', base_tag, '--metadata-file', str(provenance/'toolchain-build.json'), '.'] + run('docker', 'buildx', 'use', 'default') + started = time.time() + run(*base_command) + image = json.loads(capture('docker', 'image', 'inspect', base_tag))[0] + write(provenance/'toolchain-image.json', {k:image.get(k) for k in ['Id','RepoDigests','Architecture','Os','Created']}) + tool_versions = capture('docker', 'run', '--rm', base_tag, 'sh', '-c', 'clang --version && cmake --version && ninja --version') + (provenance/'tool-versions.txt').write_text(tool_versions+'\n') + # Metadata is an output-only addition to upstream's generated argv, with identical lane settings. + build_command = json.loads(capture('node', '.github/scripts/lanes.mjs', 'build', label, '--output', str(artifact), '--base-image', base_tag, '--dry-run')) + build_command[-1:-1] = ['--metadata-file', str(provenance/'artifact-build.json')] + run(*build_command) + with (artifact/'include/cmakeconfig.h').open('a') as f: + f.write('\n#define BUN_WEBKIT_VERSION "'+sha+'"\n') + write(artifact/'package.json', {'name':label,'version':'0.0.1-'+sha,'os':[lane['package_os']],'cpu':[lane['package_cpu']],'repository':'https://github.com/openclaw/WebKit'}) + for path in (artifact/'lib').glob('*.so*'): + if path.is_file() or path.is_symlink(): + path.unlink() + archive = destination/(label+'.tar.gz') + run('tar', '-czf', str(archive), '-C', str(destination), 'bun-webkit') + paths = capture('git', 'ls-files', '--', *RECIPE).splitlines() + patches = [] + for commit in capture('git','rev-list','--reverse',BASE+'..'+sha).splitlines(): + patch = subprocess.check_output(['git','show','--format=','--binary',commit]) + patches.append({'commit':commit,'subject':capture('git','show','-s','--format=%s',commit),'patch_sha256':hashlib.sha256(patch).hexdigest()}) + manifest = {'schema_version':1,'source':sha,'base':BASE,'lane':lane,'patches':patches, + 'recipe_sha256':{p:digest(ROOT/p) for p in paths},'toolchain_image_id':image['Id'], + 'commands':{'base':base_command,'artifact':build_command}, + 'tool_versions':tool_versions,'docker_version':capture('docker','version'),'buildx_version':capture('docker','buildx','version'), + 'workflow_run':os.environ.get('GITHUB_RUN_ID'),'run_attempt':os.environ.get('GITHUB_RUN_ATTEMPT'), + 'elapsed_seconds':time.time()-started,'files':{archive.name:{'sha256':digest(archive),'size':archive.stat().st_size}}, + 'provenance_files':{p.name:digest(p) for p in provenance.iterdir() if p.is_file()}} + write(destination/(label+'.manifest.json'), manifest) + (destination/'SHA256SUMS').write_text(digest(archive)+' '+archive.name+'\n') + if label == 'bun-webkit-linux-amd64': + run(str(artifact/'bin/jsc'), '-e', 'print("OpenClaw WebKit ready")') + run(str(artifact/'bin/testFFI')) + shutil.rmtree(artifact) + +def main(): + p=argparse.ArgumentParser() + p.add_argument('command',choices=['preflight','build']) + p.add_argument('--lane') + p.add_argument('--output',type=Path) + a=p.parse_args() + os.chdir(ROOT) + if a.command=='preflight': preflight() + else: build(a.lane,a.output) + +if __name__ == "__main__": + main() diff --git a/.github/openclaw/preflight.sh b/.github/openclaw/preflight.sh new file mode 100644 index 0000000000000..7e5a5aeee9a08 --- /dev/null +++ b/.github/openclaw/preflight.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +uname -a +nproc +free -h +df -h . +test "$(uname -sm)" = 'Linux x86_64' +docker version +docker buildx version +docker buildx inspect --bootstrap +# The unchanged upstream recipe uses tmpfs and one compiler per visible CPU. +# Report measured capacity; do not silently change its compilation settings. +python3 - <<'PY' +import json,os,shutil +from pathlib import Path +m={s.split(':')[0]:int(s.split()[1])*1024 for s in Path('/proc/meminfo').read_text().splitlines() if s.split()[1].isdigit()} +print(json.dumps({'cpu':os.cpu_count(),'memory_bytes':m['MemTotal'],'available_bytes':m['MemAvailable'],'disk_free_bytes':shutil.disk_usage('.').free})) +PY diff --git a/.github/openclaw/qualification/compare-fork-ci.py b/.github/openclaw/qualification/compare-fork-ci.py new file mode 100644 index 0000000000000..83a97a6a3553a --- /dev/null +++ b/.github/openclaw/qualification/compare-fork-ci.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Compare complete CI outcomes, including failures, without declaring them green.""" +import argparse +import json +from pathlib import Path + +p = argparse.ArgumentParser() +p.add_argument('upstream', type=Path) +p.add_argument('local', type=Path) +p.add_argument('--output', required=True, type=Path) +a = p.parse_args() + +def read(root): + selected = json.loads((root / 'selected.json').read_text()) + rows = json.loads((root / 'results.json').read_text()) + assert selected and len(selected) == len(set(selected)), 'empty/duplicate selection' + by_file = {} + for row in rows: + by_file.setdefault(row['testPath'], []).append((row['ok'], row.get('status'), row.get('exitCode'), row.get('signalCode'))) + assert set(selected) <= set(by_file), 'missing selected result files' + return selected, {name: sorted(values, key=repr) for name, values in by_file.items()} + +left_selection, left = read(a.upstream) +right_selection, right = read(a.local) +same = left_selection == right_selection and left == right +result = {'identical_file_outcomes': same, 'selected_files': len(left_selection), + 'result_files': len(left), 'additional_runner_results': sorted(set(left) - set(left_selection)), + 'all_passed': all(value[0] for values in left.values() for value in values), + 'differences': sorted(name for name in set(left) | set(right) if left.get(name) != right.get(name))} +a.output.write_text(json.dumps(result, indent=2) + '\n') +print(json.dumps(result)) +raise SystemExit(0 if same else 1) diff --git a/.github/openclaw/qualification/module-context.mjs b/.github/openclaw/qualification/module-context.mjs new file mode 100644 index 0000000000000..afb18920575b5 --- /dev/null +++ b/.github/openclaw/qualification/module-context.mjs @@ -0,0 +1,186 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire, registerHooks } from 'node:module'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const cases = ['static', 'dynamic', 'nested', 'tla', 'tla-dependency', 'cjs', 'cjs-direct', 'concurrent-distinct', 'concurrent-shared', 'concurrent-shared-reverse', 'concurrent-overlap', 'cached', 'unscoped', 'throw']; +const variant = process.argv[2] === 'matrix' ? undefined : process.argv[2]; +const nativeHooks = process.argv.includes('--native-hooks'); +if (!variant) { + const results = cases.map(name => { + const child = spawnSync(process.execPath, [fileURLToPath(import.meta.url), name, ...process.argv.slice(3)], { encoding: 'utf8', timeout: 30000 }); + let result; + try { result = JSON.parse(child.stdout); } + catch { result = { error: 'Child did not emit JSON', stdout: child.stdout, spawnError: child.error?.message }; } + return { variant: name, exitCode: child.status, signal: child.signal, stderr: child.stderr, ...result }; + }); + await new Promise(resolve => process.stdout.write(JSON.stringify({ runtime: process.version, bun: process.versions.bun, results }, null, 2) + '\n', resolve)); + process.exit(results.every(row => row.exitCode === 0) ? 0 : 1); +} +if (!cases.includes(variant)) throw new Error('Unknown variant: ' + variant); + +const root = realpathSync(mkdtempSync(path.join(tmpdir(), 'w136-als-'))); +const als = new AsyncLocalStorage(); +const trace = []; +const record = (stage, file, parent) => trace.push({ stage, file, store: als.getStore() ?? null, ...(parent ? { parent } : {}) }); +globalThis.w136Record = record; +globalThis.w136Store = () => als.getStore() ?? null; +const write = (file, source) => { + const name = path.join(root, file); + mkdirSync(path.dirname(name), { recursive: true }); + writeFileSync(name, source); +}; +const body = file => `globalThis.w136Record('body', ${JSON.stringify(file)}); export const store = globalThis.w136Store();`; +const normalize = file => file.replace(pathToFileURL(root).href + '/', '').replace(root + '/', ''); +const interesting = file => file.startsWith(root) || file.startsWith(pathToFileURL(root).href) || file.startsWith('./'); +const importFile = file => import(pathToFileURL(path.join(root, file)).href); +let hook; +let values; +let error; +const failures = []; +try { + write('package.json', '{"type":"module"}'); + als.run('REGISTRATION', () => { + if (process.versions.bun && !nativeHooks) { + Bun.plugin({ name: 'w136-observe-module-context', setup(build) { + build.onResolve({ filter: /.*/, namespace: 'file' }, ({ path: file, importer }) => { + if (interesting(file)) record('resolve', normalize(file), importer && normalize(importer)); + }); + build.onLoad({ filter: /\.mjs$/, namespace: 'file' }, ({ path: file }) => { + if (interesting(file)) record('load', normalize(file)); + return { contents: readFileSync(file, 'utf8'), loader: 'js' }; + }); + } }); + } else { + hook = registerHooks({ + resolve(file, context, next) { + if (interesting(file)) record('resolve', normalize(file), context.parentURL && normalize(context.parentURL)); + return next(file, context); + }, + load(file, context, next) { + if (interesting(file)) record('load', normalize(file)); + return next(file, context); + }, + }); + } + }); + write('leaf.mjs', body('leaf')); + write('nested.mjs', body('nested')); + let entry = body('entry'); + if (variant === 'static' || variant === 'unscoped' || variant === 'cached') + entry = `import './leaf.mjs'; ${entry}`; + if (variant === 'dynamic' || variant === 'nested') { + if (variant === 'nested') write('leaf.mjs', `${body('leaf')} await import('./nested.mjs'); globalThis.w136Record('after-nested', 'leaf');`); + entry = `${entry} await import('./leaf.mjs'); globalThis.w136Record('after-dynamic', 'entry');`; + } + if (variant === 'tla') entry = `${entry} await Promise.resolve(); globalThis.w136Record('after-await', 'entry'); await new Promise(r => setImmediate(r)); globalThis.w136Record('after-immediate', 'entry');`; + if (variant === 'tla-dependency') { + write('leaf.mjs', `${body('leaf')} await new Promise(r => setImmediate(r)); globalThis.w136Record('after-await', 'leaf');`); + entry = `import './leaf.mjs'; ${entry}`; + } + if (variant === 'cjs' || variant === 'cjs-direct') { + write('leaf.cjs', `globalThis.w136Record('body', 'cjs'); module.exports = globalThis.w136Store();`); + entry = `import {createRequire} from 'node:module'; ${entry} export const cjsStore = createRequire(import.meta.url)('./leaf.cjs');`; + } + if (variant === 'throw') entry = `${entry} throw new Error('expected-w136');`; + write('entry.mjs', entry); + const run = (store, file = 'entry.mjs') => als.run(store, async () => { + record('caller-before', file); + try { + const module = await importFile(file); + record('caller-after', file); + return { store: module.store, cjsStore: module.cjsStore }; + } catch (e) { + record('caller-catch', file); + if (variant !== 'throw' || e.message !== 'expected-w136') throw e; + return { error: e.message }; + } + }); + if (variant === 'cjs-direct') { + values = [als.run('A', () => createRequire(import.meta.url)(path.join(root, 'leaf.cjs')))]; + } else if (variant === 'concurrent-distinct') { + for (const name of ['a', 'b']) { + write(`${name}/leaf.mjs`, `${body(name + '/leaf')} await new Promise(r => setImmediate(r)); globalThis.w136Record('after-await', '${name}/leaf');`); + write(`${name}/entry.mjs`, `import './leaf.mjs'; ${body(name + '/entry')} await import('./nested.mjs');`); + write(`${name}/nested.mjs`, body(name + '/nested')); + } + values = await Promise.all([run('A', 'a/entry.mjs'), run('B', 'b/entry.mjs')]); + } else if (variant.startsWith('concurrent-shared') || variant === 'concurrent-overlap') { + let release; + let started; + globalThis.w136Gate = new Promise(r => { release = r; }); + const ready = new Promise(r => { started = r; }); + globalThis.w136Started = started; + write('leaf.mjs', `${body('leaf')} globalThis.w136Started(); await globalThis.w136Gate; globalThis.w136Record('after-await', 'leaf');`); + write('entry.mjs', `import './leaf.mjs'; ${body('entry')}`); + let otherStarted; + const otherReady = new Promise(r => { otherStarted = r; }); + globalThis.w136OtherStarted = otherStarted; + write('ready.mjs', `${body('ready')} globalThis.w136OtherStarted();`); + write('other.mjs', `import './leaf.mjs'; import './ready.mjs'; ${body('other')}`); + const stores = variant.endsWith('reverse') ? ['B', 'A'] : ['A', 'B']; + const first = run(stores[0]); + await Promise.race([ready, first]); + const second = run(stores[1], variant === 'concurrent-overlap' ? 'other.mjs' : 'entry.mjs'); + if (variant === 'concurrent-overlap') await Promise.race([otherReady, second]); + release(); + values = await Promise.all([first, second]); + } else if (variant === 'cached') { + values = [await run('A'), await run('B')]; + } else { + values = [await run(variant === 'unscoped' ? undefined : 'A')]; + } + record('outside', 'runner'); + const first = variant === 'unscoped' ? null : variant.endsWith('reverse') ? 'B' : 'A'; + let rootResolves = 0; + let callerBefore = 0; + let callerAfter = 0; + const repeated = variant.startsWith('concurrent-shared') || variant === 'cached'; + for (const event of trace) { + let expected = first; + if (event.stage === 'outside') expected = null; + else if (variant === 'concurrent-distinct') expected = (event.file.startsWith('b/') || event.parent?.startsWith('b/')) ? 'B' : 'A'; + else if (variant === 'concurrent-overlap' && (['other.mjs', 'ready', 'ready.mjs'].includes(event.file) || event.parent === 'other.mjs')) expected = 'B'; + else if (repeated) { + if (event.stage === 'resolve' && event.file === 'entry.mjs' && rootResolves++ > 0) expected = first === 'A' ? 'B' : 'A'; + if (event.stage === 'caller-before' && callerBefore++ > 0) expected = first === 'A' ? 'B' : 'A'; + if (event.stage === 'caller-after' && callerAfter++ > 0) expected = first === 'A' ? 'B' : 'A'; + } + if (event.store !== expected) failures.push({ ...event, expected }); + } + const bodyFiles = variant === 'concurrent-distinct' ? ['a/leaf', 'a/entry', 'a/nested', 'b/leaf', 'b/entry', 'b/nested'] + : variant === 'cjs-direct' ? ['cjs'] + : variant === 'cjs' ? ['entry', 'cjs'] + : variant === 'concurrent-overlap' ? ['leaf', 'entry', 'ready', 'other'] + : variant === 'nested' ? ['entry', 'leaf', 'nested'] + : ['tla', 'throw'].includes(variant) ? ['entry'] : ['leaf', 'entry']; + for (const file of bodyFiles) { + const count = trace.filter(e => e.stage === 'body' && e.file === file).length; + if (count !== 1) failures.push({ stage: 'body-count', file, count, expected: 1 }); + } + for (const stage of ['resolve', 'load']) { + for (const file of bodyFiles.map(file => file === 'cjs' ? 'leaf.cjs' : file + '.mjs')) { + // Keep require(CJS) native; the plugin's JS source loader changes its format. + if (stage === 'load' && file.endsWith('.cjs') && process.versions.bun && !nativeHooks) continue; + const seen = trace.some(event => { + if (event.stage !== stage) return false; + const resolved = event.file.startsWith('./') + ? path.posix.normalize(path.posix.join(path.posix.dirname(event.parent ?? ''), event.file)) + : event.file; + return resolved === file; + }); + if (!seen) failures.push({ stage: 'missing-hook', hook: stage, file }); + } + } + if (failures.length) process.exitCode = 1; +} catch (e) { + error = { name: e.name, message: e.message, stack: e.stack }; + process.exitCode = 1; +} finally { + hook?.deregister(); + rmSync(root, { recursive: true, force: true }); + console.log(JSON.stringify({ variant, values, error, failures, trace })); +} diff --git a/.github/openclaw/qualification/patches/000-checksums.patch b/.github/openclaw/qualification/patches/000-checksums.patch new file mode 100644 index 0000000000000..c3c46cc6e8517 --- /dev/null +++ b/.github/openclaw/qualification/patches/000-checksums.patch @@ -0,0 +1,282 @@ +diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts +index d2f910c888..3a22691543 100644 +--- a/scripts/build/deps/webkit.ts ++++ b/scripts/build/deps/webkit.ts +@@ -40,10 +40,12 @@ export const WEBKIT_VERSION = "1600131e46b5af48bbda3559af8d8a3327230b6e"; + */ + + import { homedir } from "node:os"; ++import { readFileSync } from "node:fs"; + import { join, resolve } from "node:path"; + import type { Config } from "../config.ts"; + import { computeCpuTargetFlags } from "../flags.ts"; + import { slash } from "../shell.ts"; ++import { assert } from "../error.ts"; + import { type Dependency, type NestedCmakeBuild, type Source, depBuildDir, depSourceDir } from "../source.ts"; + + // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +@@ -200,6 +202,24 @@ export const webkit: Dependency = { + identity: `${cfg.webkitVersion}${prebuiltSuffix(cfg)}`, + destDir: prebuiltDestDir(cfg), + }; ++ const manifestPath = process.env.BUN_WEBKIT_ARTIFACT_MANIFEST; ++ if (manifestPath) { ++ const manifest = JSON.parse(readFileSync(resolve(cfg.cwd, manifestPath), "utf8")); ++ assert(manifest.version === cfg.webkitVersion, "WebKit artifact manifest version does not match the configured pin"); ++ const name = new URL(src.url).pathname.split("/").at(-1)!; ++ const artifact = manifest.artifacts?.[name]; ++ assert(artifact && /^[a-f0-9]{64}$/.test(artifact.sha256), `Missing SHA-256 for ${name}`); ++ assert(typeof artifact.url === "string" && /^https?:\/\//.test(artifact.url), `Missing HTTP(S) URL for ${name}`); ++ assert(URL.canParse(artifact.url), "Invalid WebKit artifact URL"); ++ const parsedUrl = new URL(artifact.url); ++ assert( ++ !parsedUrl.username && !parsedUrl.password && !parsedUrl.search && !parsedUrl.hash, ++ "WebKit proof manifests require public URLs without credentials, query strings, or fragments", ++ ); ++ src.url = artifact.url; ++ src.sha256 = artifact.sha256; ++ src.destDir += `-${artifact.sha256.slice(0, 16)}`; ++ } + // macOS: bundled ICU headers conflict with system ICU. + if (cfg.darwin) { + src.rmAfterExtract = ["include/unicode"]; +diff --git a/scripts/build/download.ts b/scripts/build/download.ts +index 42d8184677..c04c2ad702 100644 +--- a/scripts/build/download.ts ++++ b/scripts/build/download.ts +@@ -46,7 +46,7 @@ + + import { spawnSync } from "node:child_process"; + import { createHash } from "node:crypto"; +-import { createWriteStream, existsSync, readFileSync } from "node:fs"; ++import { createReadStream, createWriteStream, existsSync, readFileSync } from "node:fs"; + import { chmod, copyFile, cp, lstat, mkdir, readdir, rename, rm, writeFile } from "node:fs/promises"; + import { basename, resolve } from "node:path"; + import { Readable } from "node:stream"; +@@ -369,7 +369,12 @@ export async function fetchPrebuilt( + dest: string, + identity: string, + rmPaths: string[] = [], ++ sha256?: string, + ): Promise { ++ if (sha256 !== undefined) { ++ assert(/^[a-f0-9]{64}$/.test(sha256), "prebuilt: invalid SHA-256"); ++ identity = `${identity}:sha256:${sha256}`; ++ } + const stampPath = resolve(dest, ".identity"); + + // โ”€โ”€โ”€ Short-circuit: already at this identity? โ”€โ”€โ”€ +@@ -383,7 +388,8 @@ export async function fetchPrebuilt( + } + + // โ”€โ”€โ”€ Prefetch cache: pre-extracted tree with matching identity? โ”€โ”€โ”€ +- if (await tryPrefetchExtracted(dest, ".identity", identity)) return; ++ // A pre-extracted image cache cannot prove the archive digest. ++ if (sha256 === undefined && (await tryPrefetchExtracted(dest, ".identity", identity))) return; + + console.log(`fetching ${url}`); + const started = performance.now(); +@@ -410,6 +416,11 @@ export async function fetchPrebuilt( + await mkdir(stagingDir, { recursive: true }); + + try { ++ if (sha256 !== undefined) { ++ const hash = createHash("sha256"); ++ for await (const chunk of createReadStream(tarballPath)) hash.update(chunk); ++ assert(hash.digest("hex") === sha256, `SHA-256 mismatch for ${name}`, { file: url }); ++ } + // stripComponents=0: keep top-level dir for hoisting. + await extractTarGz(tarballPath, stagingDir, 0); + await rm(tarballPath, { force: true }); +diff --git a/scripts/build/fetch-cli.ts b/scripts/build/fetch-cli.ts +index eebc97cc79..72bc406d85 100644 +--- a/scripts/build/fetch-cli.ts ++++ b/scripts/build/fetch-cli.ts +@@ -91,13 +91,15 @@ async function main(): Promise { + } + + case "prebuilt": { +- // fetch-cli.ts prebuilt [...rm_paths] +- const [name, url, dest, identity, ...rmPaths] = args; ++ // fetch-cli.ts prebuilt [...rm_paths] ++ const [name, encodedUrl, dest, identity, sha256, ...rmPaths] = args; + assert( +- name !== undefined && url !== undefined && dest !== undefined && identity !== undefined, ++ name !== undefined && encodedUrl !== undefined && dest !== undefined && identity !== undefined, + "prebuilt: missing name/url/dest/identity", + ); +- return fetchPrebuilt(name, url, dest, identity, rmPaths); ++ assert(sha256 !== undefined, "prebuilt: missing sha256 or '-' sentinel"); ++ const url = Buffer.from(encodedUrl, "base64url").toString("utf8"); ++ return fetchPrebuilt(name, url, dest, identity, rmPaths, sha256 === "-" ? undefined : sha256); + } + + case undefined: +@@ -117,7 +119,7 @@ Usage: bun fetch-cli.ts + + Kinds: + dep [...patches] +- prebuilt [...rm_paths] ++ prebuilt [...rm_paths] + subst [ ]... + check-undefined + +diff --git a/scripts/build/ninja.ts b/scripts/build/ninja.ts +index 5a02f2d67e..98478739a3 100644 +--- a/scripts/build/ninja.ts ++++ b/scripts/build/ninja.ts +@@ -67,7 +67,7 @@ const ruleVars = { + dep_codegen: ["name", "cwd", "tool", "args"], + dep_configure: ["name", "srcdir", "builddir", "args"], + dep_fetch: ["name", "repo", "commit", "dest", "cache", "patches"], +- dep_fetch_prebuilt: ["name", "url", "dest", "identity", "rm_paths"], ++ dep_fetch_prebuilt: ["name", "url", "dest", "identity", "sha256", "rm_paths"], + dep_host_cc: ["flags"], + dep_prebuild: ["name", "cwd", "cmd"], + dep_subst: ["pairs"], +diff --git a/scripts/build/source.ts b/scripts/build/source.ts +index ee3163e9f4..663c7ea31c 100644 +--- a/scripts/build/source.ts ++++ b/scripts/build/source.ts +@@ -140,6 +140,8 @@ export type Source = + * affect which tarball you need). + */ + identity: string; ++ /** SHA-256 of the archive, checked before extraction. */ ++ sha256?: string; + /** + * Paths to delete (relative to destDir) after extraction. WebKit + * deletes `include/unicode` on macOS (conflicts with system ICU +@@ -613,7 +615,7 @@ export function registerDepRules(n: Ninja, cfg: Config): void { + // $rm_paths: space-separated paths (relative to dest) to delete after + // extraction. Trailing positional args to fetch-cli, may be empty. + n.rule("dep_fetch_prebuilt", { +- command: `${stream} ${cfg.jsRuntime} ${fetchCli} prebuilt $name $url $dest $identity $rm_paths`, ++ command: `${stream} ${cfg.jsRuntime} ${fetchCli} prebuilt $name $url $dest $identity $sha256 $rm_paths`, + description: "fetch $name (prebuilt)", + restat: true, + pool: "dep", +@@ -1088,9 +1090,11 @@ function emitPrebuilt( + implicitInputs: [fetchCliPath], + vars: { + name, +- url: source.url, ++ // A manifest URL may contain shell metacharacters; decode it inside fetch-cli. ++ url: Buffer.from(source.url, "utf8").toString("base64url"), + dest: destDir, + identity: source.identity, ++ sha256: source.sha256 ?? "-", + // Space-separated relative paths. No quoting needed โ€” paths are + // under our control (include/node/openssl etc.), no spaces. + rm_paths: (source.rmAfterExtract ?? []).join(" "), +diff --git a/test/internal/build-download-retry.test.ts b/test/internal/build-download-retry.test.ts +index 76861c4ce6..b7bdb094cc 100644 +--- a/test/internal/build-download-retry.test.ts ++++ b/test/internal/build-download-retry.test.ts +@@ -17,12 +17,37 @@ import { tempDir } from "harness"; + import { existsSync, readFileSync } from "node:fs"; + import { createServer, type AddressInfo } from "node:net"; + import { join } from "node:path"; ++import { createHash } from "node:crypto"; ++import { spawnSync } from "node:child_process"; + +-import { downloadRetry, downloadWithRetry, type RetryPolicy } from "../../scripts/build/download.ts"; ++import { downloadRetry, downloadWithRetry, fetchPrebuilt, type RetryPolicy } from "../../scripts/build/download.ts"; + import { BuildError, describeError } from "../../scripts/build/error.ts"; + + const BODY = "tarball bytes"; + ++test("prebuilt archives reject a wrong digest before replacing the installed tree", async () => { ++ using dir = tempDir("prebuilt-sha256", { "package/value": "verified" }); ++ const archive = join(String(dir), "package.tar.gz"); ++ expect(spawnSync("tar", ["-czf", archive, "-C", String(dir), "package"]).status).toBe(0); ++ const bytes = readFileSync(archive); ++ const digest = createHash("sha256").update(bytes).digest("hex"); ++ const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response(bytes) }); ++ const dest = join(String(dir), "installed"); ++ try { ++ await fetchPrebuilt("fixture", server.url.href, dest, "pin", [], digest); ++ expect(readFileSync(join(dest, "value"), "utf8")).toBe("verified"); ++ await expect(fetchPrebuilt("fixture", server.url.href, dest, "pin", [], "0".repeat(64))).rejects.toThrow( ++ "SHA-256 mismatch for fixture", ++ ); ++ expect(readFileSync(join(dest, "value"), "utf8")).toBe("verified"); ++ await expect(fetchPrebuilt("fixture", server.url.href, dest, "pin", [], "invalid")).rejects.toThrow( ++ "prebuilt: invalid SHA-256", ++ ); ++ } finally { ++ server.stop(true); ++ } ++}); ++ + /** The production attempt count with no waiting between attempts. */ + const noBackoff: RetryPolicy = { ...downloadRetry, backoffMs: () => 0 }; + +diff --git a/test/internal/source-lints/webkit-prebuilt-url.test.ts b/test/internal/source-lints/webkit-prebuilt-url.test.ts +index dea23b7f0e..919cdbdfbd 100644 +--- a/test/internal/source-lints/webkit-prebuilt-url.test.ts ++++ b/test/internal/source-lints/webkit-prebuilt-url.test.ts +@@ -2,9 +2,14 @@ + * WEBKIT_VERSION is used as the release tag; --webkit-version overrides still + * hit the plain `autobuild-` tag. Configure-time only. */ + import { describe, expect, test } from "bun:test"; ++import { tempDir } from "harness"; ++import { writeFileSync } from "node:fs"; ++import { join } from "node:path"; + + import { resolveConfig, type Config, type PartialConfig, type Toolchain } from "../../../scripts/build/config.ts"; + import { webkit, WEBKIT_VERSION } from "../../../scripts/build/deps/webkit.ts"; ++import { Ninja } from "../../../scripts/build/ninja.ts"; ++import { registerDepRules, resolveDep } from "../../../scripts/build/source.ts"; + + /** A fully-populated fake toolchain โ€” resolveConfig never spawns any of these. */ + function mockToolchain(): Toolchain { +@@ -69,6 +74,48 @@ function prebuiltUrlOf(cfg: Config): string { + } + + describe("WebKit prebuilt URL", () => { ++ test("an explicit artifact manifest pins URL, checksum and cache, and rejects missing variants", () => { ++ using dir = tempDir("webkit-manifest", {}); ++ const path = join(String(dir), "artifacts.json"); ++ const prior = process.env.BUN_WEBKIT_ARTIFACT_MANIFEST; ++ const digest = "a".repeat(64); ++ const url = "https://example.invalid/engine%20release&candidate.tar.gz"; ++ const manifest = { ++ version: WEBKIT_VERSION, ++ artifacts: { "bun-webkit-linux-amd64.tar.gz": { url, sha256: digest } }, ++ }; ++ try { ++ process.env.BUN_WEBKIT_ARTIFACT_MANIFEST = path; ++ writeFileSync(path, JSON.stringify(manifest)); ++ const source = webkit.source(resolveLinuxRelease()); ++ expect(source.kind).toBe("prebuilt"); ++ if (source.kind !== "prebuilt") throw new Error("expected prebuilt"); ++ expect(source.url).toBe(url); ++ expect(source.sha256).toBe(digest); ++ expect(source.destDir).toEndWith(`-${digest.slice(0, 16)}`); ++ const cfg = resolveLinuxRelease({ buildDir: join(String(dir), "build") }); ++ const ninja = new Ninja({ buildDir: cfg.buildDir }); ++ registerDepRules(ninja, cfg); ++ resolveDep(ninja, cfg, webkit, new Map()); ++ const encodedUrl = /^ url = (.*)$/m.exec(ninja.toString())![1]!; ++ expect(encodedUrl).toMatch(/^[A-Za-z0-9_-]+$/); ++ expect(Buffer.from(encodedUrl, "base64url").toString("utf8")).toBe(url); ++ for (const rejectedUrl of ["https://user:secret@example.invalid/a", `${url}?signature=secret`, `${url}#secret`]) { ++ writeFileSync(path, JSON.stringify({ ++ ...manifest, ++ artifacts: { "bun-webkit-linux-amd64.tar.gz": { url: rejectedUrl, sha256: digest } }, ++ })); ++ expect(() => webkit.source(resolveLinuxRelease())).toThrow("without credentials, query strings, or fragments"); ++ } ++ writeFileSync(path, JSON.stringify(manifest)); ++ expect(() => webkit.source(resolveLinuxRelease({ lto: true }))).toThrow("Missing SHA-256"); ++ writeFileSync(path, JSON.stringify({ ...manifest, version: "wrong" })); ++ expect(() => webkit.source(resolveLinuxRelease())).toThrow("manifest version does not match"); ++ } finally { ++ if (prior === undefined) delete process.env.BUN_WEBKIT_ARTIFACT_MANIFEST; ++ else process.env.BUN_WEBKIT_ARTIFACT_MANIFEST = prior; ++ } ++ }); + // Mirrors prebuiltUrl(): 40-hex shas get the autobuild- prefix, tags pass + // through, so these assertions hold for both WEBKIT_VERSION forms. + const defaultTag = WEBKIT_VERSION.startsWith("autobuild-") ? WEBKIT_VERSION : `autobuild-${WEBKIT_VERSION}`; diff --git a/.github/openclaw/qualification/patches/001-proxy.patch b/.github/openclaw/qualification/patches/001-proxy.patch new file mode 100644 index 0000000000000..8923a87369a2d --- /dev/null +++ b/.github/openclaw/qualification/patches/001-proxy.patch @@ -0,0 +1,84 @@ +diff --git a/src/jsc/bindings/BunGlobalScope.cpp b/src/jsc/bindings/BunGlobalScope.cpp +index 694e720fe5..b576f6b9f5 100644 +--- a/src/jsc/bindings/BunGlobalScope.cpp ++++ b/src/jsc/bindings/BunGlobalScope.cpp +@@ -18,6 +18,11 @@ void GlobalScope::finishCreation(JSC::VM& vm) + Base::finishCreation(vm); + ASSERT(inherits(info())); + ++ // Every Bun global clears IsImmutablePrototypeExoticObject from its structure, so user ++ // code can put a Proxy in its prototype chain (jsdom does, for its window). V8 runs ++ // programs against such a chain, so JSC must not reject it. ++ setAllowsProxyInPrototypeChain(true); ++ + m_encodeIntoObjectStructure.initLater( + [](const JSC::LazyProperty::Initializer& init) { + auto& vm = init.vm; +diff --git a/test/js/node/vm/vm.test.ts b/test/js/node/vm/vm.test.ts +index 45922451e0..9ccbced050 100644 +--- a/test/js/node/vm/vm.test.ts ++++ b/test/js/node/vm/vm.test.ts +@@ -1808,6 +1808,63 @@ describe("DONT_CONTEXTIFY", () => { + }); + }); + ++// https://github.com/oven-sh/bun/issues/42331 ++describe("a Proxy in the prototype chain of the context global", () => { ++ // jsdom 28+ puts a Proxy (WindowProperties) in the prototype chain of its ++ // window. V8 runs a program against such a global, so node:vm must too. ++ function installProxy(ctx: object) { ++ const target = { fromProxy: 7 }; ++ const g = runInContext("this", ctx); ++ Object.setPrototypeOf(g, Object.create(new Proxy(target, {}))); ++ return target; ++ } ++ ++ test.each([ ++ ["DONT_CONTEXTIFY", () => createContext(constants.DONT_CONTEXTIFY)], ++ ["contextified sandbox", () => createContext({})], ++ ])("%s: programs run and resolve names through the Proxy", (_, makeContext) => { ++ const ctx = makeContext(); ++ const target = installProxy(ctx); ++ ++ expect(runInContext("1 + 1", ctx)).toBe(2); ++ expect(new Script("2 + 2").runInContext(ctx)).toBe(4); ++ expect(runInContext("fromProxy", ctx)).toBe(7); ++ expect(runInContext("typeof missingName", ctx)).toBe("undefined"); ++ expect(runInContext("undeclaredAssign = 9; undeclaredAssign", ctx)).toBe(9); ++ expect(runInContext("fromProxy = 8; fromProxy", ctx)).toBe(8); ++ expect(target.fromProxy).toBe(7); ++ }); ++ ++ test("contextified sandbox: declarations land on the global, not on the Proxy", () => { ++ const ctx = createContext({}); ++ const target = installProxy(ctx); ++ ++ expect(runInContext("var declared = 5; function fn() { return 6 } declared + fn()", ctx)).toBe(11); ++ expect(new Script("declared * 2").runInContext(ctx)).toBe(10); ++ expect(target).toEqual({ fromProxy: 7 }); ++ }); ++ ++ test("main realm: runInThisContext and require() run with a Proxy in the chain of globalThis", async () => { ++ using dir = tempDir("vm-proxy-global", { "dep.cjs": "module.exports = 42;" }); ++ await using proc = Bun.spawn({ ++ cmd: [ ++ bunExe(), ++ "-e", ++ `const vm = require("node:vm"); ++ Object.setPrototypeOf(globalThis, Object.create(new Proxy({ viaProxy: 3 }, {}))); ++ console.log(vm.runInThisContext("1 + 1"), vm.runInThisContext("viaProxy"), require("./dep.cjs"));`, ++ ], ++ env: bunEnv, ++ cwd: String(dir), ++ stderr: "pipe", ++ }); ++ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); ++ expect(stderr).toBe(""); ++ expect(stdout).toBe("2 3 42\n"); ++ expect(exitCode).toBe(0); ++ }); ++}); ++ + describe("defineProperty errors use vm-realm global", () => { + test("data descriptor on sandbox-only property", () => { + const sandbox = {}; diff --git a/.github/openclaw/qualification/patches/003-bun-worker-resource-limits.patch b/.github/openclaw/qualification/patches/003-bun-worker-resource-limits.patch new file mode 100644 index 0000000000000..dcfffb21af5d5 --- /dev/null +++ b/.github/openclaw/qualification/patches/003-bun-worker-resource-limits.patch @@ -0,0 +1,674 @@ +From 0b06a938bb854917852b4ca3788ebd9331fda32a Mon Sep 17 00:00:00 2001 +From: Peter Steinberger +Date: Sat, 3 Oct 2026 08:47:50 -0700 +Subject: [PATCH] feat(worker): bind per-VM resource limits and heap + termination + +Local-only dependent-engine prototype; do not merge with the stock WebKit artifact. Adapt worker reporting and termination ownership from oven-sh/bun#32896, with external backing stores excluded by the new full-GC hook. Add nursery and per-VM stack sizing plus worker-only OOM regressions. Code range remains reporting-only; platform defaults and startup edge cases still require qualification. +--- + CHANGELOG.md | 2 + + docs/runtime/workers.mdx | 11 +++ + packages/bun-types/bun.d.ts | 19 +++- + src/js/node/worker_threads.ts | 9 +- + src/jsc/bindings/ErrorCode.ts | 1 + + src/jsc/bindings/webcore/JSWorker.cpp | 42 +++++++++ + src/jsc/bindings/webcore/Worker.cpp | 19 +++- + src/jsc/bindings/webcore/Worker.h | 7 +- + .../bindings/webcore/WorkerMessagingProxy.cpp | 92 ++++++++++++++++++- + .../bindings/webcore/WorkerMessagingProxy.h | 12 +++ + src/jsc/bindings/webcore/WorkerOptions.h | 31 +++++++ + src/jsc/web_worker.rs | 22 ++++- + .../worker_threads/worker_threads.test.ts | 79 ++++++++++++++++ + 13 files changed, 332 insertions(+), 14 deletions(-) + +diff --git a/CHANGELOG.md b/CHANGELOG.md +index b815c47..6dd3bc3 100644 +--- a/CHANGELOG.md ++++ b/CHANGELOG.md +@@ -145,3 +145,5 @@ + - Count allocations since the most recent garbage collection in process, V8-compatible, and worker heap statistics, including newly retained JavaScript array storage. + + - Publish cached resolver paths once so worker resolution and filesystem-router reloads cannot expose truncated symlink targets. Adapts [oven-sh/bun#40258](https://github.com/oven-sh/bun/pull/40258). Thanks @robobun! ++ ++- Add experimental Node worker resource-limit reporting, per-VM managed-heap termination, nursery sizing, and JavaScript stack limits with a matching local WebKit build. Adapts [oven-sh/bun#32896](https://github.com/oven-sh/bun/pull/32896). Thanks @robobun! +diff --git a/docs/runtime/workers.mdx b/docs/runtime/workers.mdx +index 616d00d..7ff82f7 100644 +--- a/docs/runtime/workers.mdx ++++ b/docs/runtime/workers.mdx +@@ -327,3 +327,14 @@ if (Bun.isMainThread) { + console.log("I'm in a worker"); + } + ``` ++ ++ ++## Node worker resource limits ++ ++`node:worker_threads` accepts `resourceLimits` and exposes the effective values through `worker.resourceLimits` and the worker's `resourceLimits` export. The parent property becomes an empty object when the worker exits. ++ ++`maxOldGenerationSizeMb` limits the managed heap after a full garbage collection. ArrayBuffer backing storage is excluded. A worker over the limit emits `ERR_WORKER_OUT_OF_MEMORY` and exits with code 1. `maxYoungGenerationSizeMb` sizes the nursery without independently limiting the retained heap. `stackSizeMb` controls JavaScript stack checks; the native stack retains initialization and exception-handling headroom. ++ ++`codeRangeSizeMb` is reported for compatibility. JSC shares its executable allocator across workers, so this option does not reserve executable memory per worker. ++ ++This local prototype uses 4096 MiB old-generation and 192 MiB nursery defaults, matching the qualified Linux x64 Node 24 host. Default sizing on memory-constrained hosts, command-line heap overrides, and very small startup limits require further qualification before release. +diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts +index 1769d75..48beeef 100644 +--- a/packages/bun-types/bun.d.ts ++++ b/packages/bun-types/bun.d.ts +@@ -704,7 +704,17 @@ declare module "bun" { + * @default true + */ + // trackUnmanagedFds?: boolean; +- // resourceLimits?: import("worker_threads").ResourceLimits; ++ ++ /** ++ * Resource limits for the worker's JS engine, in megabytes, with the same ++ * shape as Node.js. `maxOldGenerationSizeMb` caps the worker's heap: a ++ * worker whose heap is still above it after a full garbage collection is ++ * terminated and emits an `ERR_WORKER_OUT_OF_MEMORY` error event. The ++ * young-generation option sizes the nursery. `stackSizeMb` sets the JS ++ * stack capacity. `codeRangeSizeMb` is reported without a per-worker ++ * executable-memory reservation. These limits apply to Node workers. ++ */ ++ resourceLimits?: import("node:worker_threads").ResourceLimits; + + /** + * An array of module specifiers to preload in the worker. +@@ -775,6 +785,13 @@ declare module "bun" { + * This value is unique for each `Worker` instance inside a single process. + */ + threadId: number; ++ ++ /** ++ * The JS engine resource constraints this worker was created with, or ++ * an empty object once the worker has stopped. Inside the worker thread, ++ * it is available as `require('node:worker_threads').resourceLimits`. ++ */ ++ readonly resourceLimits: import("node:worker_threads").ResourceLimits; + } + + interface Env { +diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts +index b8995f0..1d5e152 100644 +--- a/src/js/node/worker_threads.ts ++++ b/src/js/node/worker_threads.ts +@@ -88,6 +88,7 @@ const { + 16: WebWorker, + 17: _workerHasRef, + 18: _workerEventLoopUtilization, ++ 19: resourceLimits, + } = $cpp("Worker.cpp", "createNodeWorkerThreadsBinding") as [ + unknown, + number, +@@ -113,6 +114,7 @@ const { + ) => WebWorker, + (worker: WebWorker) => boolean | undefined, + (worker: WebWorker) => [number, number] | null, ++ Record, + ]; + + type NodeWorkerOptions = import("node:worker_threads").WorkerOptions; +@@ -342,8 +344,6 @@ Object.defineProperty(MessagePort.prototype, kInspectCustom, { + configurable: true, + }); + +-let resourceLimits = {}; +- + const BUN_WORKER_STDIO_KEY = "@@bunWorkerThreadsStdio"; + const BUN_WORKER_MESSAGING_KEY = "@@bunWorkerThreadsMessaging"; + // The worker's `parentPort`: port2 of a channel whose port1 is the parent +@@ -1078,6 +1078,11 @@ class Worker extends EventEmitter { + return this.#exited ? null : this.#name; + } + ++ get resourceLimits() { ++ // Read back from the single native parse; {} once the worker stopped. ++ return this.#worker.resourceLimits; ++ } ++ + ref() { + // node's ref()/unref() touch the handle and the public port; stdio ports' + // ref state tracks in-flight I/O. +diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts +index 458507b..29c4417 100644 +--- a/src/jsc/bindings/ErrorCode.ts ++++ b/src/jsc/bindings/ErrorCode.ts +@@ -373,5 +373,6 @@ const errors: ErrorCodeMapping = [ + ["ERR_INSPECTOR_COMMAND", Error], + ["ERR_REDIS_SERVER_ERROR", Error, "RedisError"], + ["ERR_FFI_CC_DISABLED", Error], ++ ["ERR_WORKER_OUT_OF_MEMORY", Error], + ]; + export default errors; +diff --git a/src/jsc/bindings/webcore/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp +index 87f62c0..884f644 100644 +--- a/src/jsc/bindings/webcore/JSWorker.cpp ++++ b/src/jsc/bindings/webcore/JSWorker.cpp +@@ -481,6 +481,33 @@ template<> __attribute__((minsize)) JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES + RETURN_IF_EXCEPTION(throwScope, {}); + } + ++ JSValue resourceLimitsValue = optionsObject->getIfPropertyExists(lexicalGlobalObject, Identifier::fromString(vm, "resourceLimits"_s)); ++ RETURN_IF_EXCEPTION(throwScope, {}); ++ // As in Node's parseResourceLimits: non-objects, functions and non-number fields are ignored. ++ if (resourceLimitsValue && resourceLimitsValue.isObject() && !resourceLimitsValue.isCallable()) { ++ auto* limitsObject = asObject(resourceLimitsValue); ++ WorkerResourceLimits limits; ++ auto readLimit = [&](ASCIILiteral key, double& out, std::optional floor = std::nullopt) -> bool { ++ JSValue value = limitsObject->getIfPropertyExists(lexicalGlobalObject, Identifier::fromString(vm, key)); ++ if (throwScope.exception()) ++ return false; ++ if (value && value.isNumber()) ++ out = floor ? std::max(value.asNumber(), *floor) : value.asNumber(); ++ return true; ++ }; ++ if (!readLimit("maxYoungGenerationSizeMb"_s, limits.maxYoungGenerationSizeMb)) return {}; ++ // Node floors this one at 2 MB (MathMax(value, 2)). ++ if (!readLimit("maxOldGenerationSizeMb"_s, limits.maxOldGenerationSizeMb, 2.0)) return {}; ++ if (!readLimit("codeRangeSizeMb"_s, limits.codeRangeSizeMb)) return {}; ++ if (!readLimit("stackSizeMb"_s, limits.stackSizeMb)) return {}; ++ // Node replaces a non-positive stack size with its 4 MB default. ++ if (!(limits.stackSizeMb > 0)) ++ limits.stackSizeMb = 4; ++ else ++ limits.stackSizeMb = std::max(limits.stackSizeMb, 192.0 / 1024.0); ++ options.resourceLimits = limits; ++ } ++ + JSValue execArgvValue = optionsObject->getIfPropertyExists(lexicalGlobalObject, Identifier::fromString(vm, "execArgv"_s)); + RETURN_IF_EXCEPTION(throwScope, {}); + if (execArgvValue && execArgvValue.pureToBoolean() != TriState::False) { +@@ -608,6 +635,20 @@ JSC_DEFINE_CUSTOM_GETTER(jsWorker_threadIdGetter, (JSGlobalObject * lexicalGloba + return JSValue::encode(jsNumber(worker.clientIdentifier() - 1)); + } + ++JSC_DEFINE_CUSTOM_GETTER(jsWorker_resourceLimitsGetter, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName)) ++{ ++ auto* castedThis = dynamicDowncast(JSValue::decode(thisValue)); ++ if (!castedThis) [[unlikely]] ++ return JSValue::encode(jsUndefined()); ++ ++ auto& worker = castedThis->wrapped(); ++ // Node reports {} once the thread has exited (the proxy is already Closing inside the OOM 'error' handler). ++ if (worker.hasExited()) ++ return JSValue::encode(constructEmptyObject(lexicalGlobalObject)); ++ auto limits = worker.contextProxy().options().resourceLimits; ++ return JSValue::encode(createResourceLimitsObject(lexicalGlobalObject, worker.contextProxy().isOnline() ? limits.resolved() : limits)); ++} ++ + /* Hash table for prototype */ + + static const HashTableValue JSWorkerPrototypeTableValues[] = { +@@ -617,6 +658,7 @@ static const HashTableValue JSWorkerPrototypeTableValues[] = { + { "onmessageerror"_s, JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::DOMAttribute, NoIntrinsic, { HashTableValue::GetterSetterType, jsWorker_onmessageerror, setJSWorker_onmessageerror } }, + { "postMessage"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_postMessage, 1 } }, + { "ref"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_ref, 0 } }, ++ { "resourceLimits"_s, JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::DOMAttribute | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete, NoIntrinsic, { HashTableValue::GetterSetterType, jsWorker_resourceLimitsGetter, nullptr } }, + { "terminate"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_terminate, 0 } }, + { "threadId"_s, JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::DOMAttribute | JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete, NoIntrinsic, { HashTableValue::GetterSetterType, jsWorker_threadIdGetter, nullptr } }, + { "unref"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsWorkerPrototypeFunction_unref, 0 } }, +diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp +index 5c00738..c1e6b69 100644 +--- a/src/jsc/bindings/webcore/Worker.cpp ++++ b/src/jsc/bindings/webcore/Worker.cpp +@@ -147,7 +147,7 @@ void Worker::dispatchEvent(Event& event) + EventTargetWithInlineData::dispatchEvent(event); + } + +-void Worker::dispatchCloseEvent(Event& event) ++void Worker::dispatchExitEvent(Event& event) + { + EventTargetWithInlineData::dispatchEvent(event); + } +@@ -250,6 +250,17 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionWorkerEventLoopUtilization, (JSGlobalObject * + RELEASE_AND_RETURN(scope, JSValue::encode(result)); + } + ++JSObject* createResourceLimitsObject(JSGlobalObject* globalObject, const WorkerResourceLimits& limits) ++{ ++ auto& vm = JSC::getVM(globalObject); ++ auto* object = constructEmptyObject(globalObject, globalObject->objectPrototype(), 4); ++ object->putDirect(vm, Identifier::fromString(vm, "maxYoungGenerationSizeMb"_s), jsNumber(limits.maxYoungGenerationSizeMb)); ++ object->putDirect(vm, Identifier::fromString(vm, "maxOldGenerationSizeMb"_s), jsNumber(limits.maxOldGenerationSizeMb)); ++ object->putDirect(vm, Identifier::fromString(vm, "codeRangeSizeMb"_s), jsNumber(limits.codeRangeSizeMb)); ++ object->putDirect(vm, Identifier::fromString(vm, "stackSizeMb"_s), jsNumber(limits.stackSizeMb)); ++ return object; ++} ++ + JSC_DEFINE_HOST_FUNCTION(jsReceiveMessageOnPort, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) + { + auto& vm = JSC::getVM(lexicalGlobalObject); +@@ -381,7 +392,7 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) + + bool isNodeWorker = proxy && proxy->options().kind == WorkerOptions::Kind::Node; + +- JSObject* array = constructEmptyArray(globalObject, nullptr, 19); ++ JSObject* array = constructEmptyArray(globalObject, nullptr, 20); + RETURN_IF_EXCEPTION(scope, {}); + array->putDirectIndex(globalObject, 0, workerData); + RETURN_IF_EXCEPTION(scope, {}); +@@ -423,6 +434,10 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) + RETURN_IF_EXCEPTION(scope, {}); + array->putDirectIndex(globalObject, 18, JSFunction::create(vm, globalObject, 1, "workerEventLoopUtilization"_s, jsFunctionWorkerEventLoopUtilization, ImplementationVisibility::Public, NoIntrinsic)); + RETURN_IF_EXCEPTION(scope, {}); ++ JSObject* resourceLimits = proxy ? createResourceLimitsObject(globalObject, proxy->options().resourceLimits.resolved()) : constructEmptyObject(globalObject); ++ RETURN_IF_EXCEPTION(scope, {}); ++ array->putDirectIndex(globalObject, 19, resourceLimits); ++ RETURN_IF_EXCEPTION(scope, {}); + return array; + } + +diff --git a/src/jsc/bindings/webcore/Worker.h b/src/jsc/bindings/webcore/Worker.h +index 09ae547..a6b2e45 100644 +--- a/src/jsc/bindings/webcore/Worker.h ++++ b/src/jsc/bindings/webcore/Worker.h +@@ -33,6 +33,7 @@ + + namespace JSC { + class JSGlobalObject; ++class JSObject; + class JSValue; + } + +@@ -71,9 +72,9 @@ public: + bool eventLoopUtilization(double& elapsedMs, double& idleMs); + + // Node worker_threads: 'message'/'error'/'messageerror' are not delivered once terminate() was +- // called; 'close' (which carries the exit code) always is. ++ // called; the final OOM error and close event are always delivered. + void dispatchEvent(Event&) final; +- void dispatchCloseEvent(Event&); ++ void dispatchExitEvent(Event&); + + const String& name() const { return m_name; } + // Both identifiers are process-unique; threadId is derived from the worker's. +@@ -100,6 +101,8 @@ private: + }; + + JSC::JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject); ++// Shared by the in-worker `resourceLimits` export and the worker.resourceLimits getter (JSWorker.cpp). ++JSC::JSObject* createResourceLimitsObject(JSC::JSGlobalObject*, const WorkerResourceLimits&); + + JSC_DECLARE_HOST_FUNCTION(jsFunctionPostMessage); + +diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp +index b752048..128c798 100644 +--- a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp ++++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp +@@ -39,6 +39,7 @@ + #include "SerializedScriptValue.h" + #include "Worker.h" + #include "ZigGlobalObject.h" ++#include + #include + #include + #include +@@ -84,7 +85,7 @@ void* WebWorker__create( + size_t execArgvRequirePreloadCount, + uint8_t execArgvEvalMode); + // Raise a TerminationException in the worker VM at its next safepoint and wake its loop. Any thread. +-void WebWorker__requestTermination(void*); ++bool WebWorker__requestTermination(void*); + // Toggle the keep-alive this worker holds on the parent event loop. Parent thread. + void WebWorker__setRef(void*, bool); + bool WebWorker__hasRef(void* worker); +@@ -99,6 +100,74 @@ void WebWorker__deref(void*); + + } // extern "C" + ++// ---- resourceLimits ------------------------------------------------------------------------------ ++ ++// didGarbageCollect runs on whichever thread finished the collection (possibly JSC's collector thread, which has no per-thread Bun state), before the mutator resumes. ++class WorkerHeapLimitObserver final : public JSC::HeapObserver { ++ WTF_MAKE_TZONE_ALLOCATED(WorkerHeapLimitObserver); ++ ++public: ++ WorkerHeapLimitObserver(WorkerMessagingProxy& proxy, JSC::VM& vm, void* workerThread) ++ : m_proxy(proxy) ++ , m_vm(vm) ++ , m_workerThread(workerThread) ++ { ++ } ++ ++private: ++ void willGarbageCollect() final {} ++ ++ void didGarbageCollect(JSC::CollectionScope scope) final ++ { ++ // Only a full collection's survivor size is the live set; an eden collection's is not. ++ if (scope != JSC::CollectionScope::Full) ++ return; ++ if (m_proxy.m_heapLimitDisarmed.load(std::memory_order_acquire)) ++ return; ++ if (!m_vm.heap.heapLimitExceeded()) ++ return; ++ // A thread that is already stopping (terminate(), process.exit()) keeps its own reason. ++ if (!WebWorker__requestTermination(m_workerThread)) ++ return; ++ m_proxy.m_stoppedByHeapLimit.store(true, std::memory_order_release); ++ } ++ ++ WorkerMessagingProxy& m_proxy; ++ JSC::VM& m_vm; ++ void* const m_workerThread; ++}; ++ ++WTF_MAKE_TZONE_ALLOCATED_IMPL(WorkerHeapLimitObserver); ++ ++void WorkerMessagingProxy::installHeapLimitObserver(JSC::VM& vm, void* workerThread) ++{ ++ if (m_options.kind != WorkerOptions::Kind::Node) ++ return; ++ auto limits = m_options.resourceLimits.resolved(); ++ vm.setWorkerStackUsage(WorkerResourceLimits::bytes(limits.stackSizeMb)); ++ vm.heap.setWorkerHeapLimits(WorkerResourceLimits::bytes(limits.maxOldGenerationSizeMb), WorkerResourceLimits::bytes(limits.maxYoungGenerationSizeMb)); ++ ASSERT(!m_heapLimitObserver); ++ m_heapLimitObserver = makeUnique(*this, vm, workerThread); ++ vm.heap.addObserver(m_heapLimitObserver.get()); ++} ++ ++extern "C" size_t WebWorker__stackSize(WorkerMessagingProxy* proxy) ++{ ++ if (proxy->options().kind != WorkerOptions::Kind::Node) ++ return 0; ++ return WorkerResourceLimits::bytes(proxy->options().resourceLimits.stackSizeMb); ++} ++ ++extern "C" void WebWorker__installHeapLimitObserver(WorkerMessagingProxy* proxy, Zig::GlobalObject* globalObject, void* workerThread) ++{ ++ proxy->installHeapLimitObserver(JSC::getVM(globalObject), workerThread); ++} ++ ++extern "C" void WebWorker__disarmHeapLimitObserver(WorkerMessagingProxy* proxy) ++{ ++ proxy->disarmHeapLimitObserver(); ++} ++ + WorkerMessagingProxy::WorkerMessagingProxy(Worker& workerObject, ScriptExecutionContext& parentContext, WorkerOptions&& options) + : m_scriptExecutionContext(&parentContext) + , m_workerObject(&workerObject) +@@ -790,6 +859,10 @@ void WorkerMessagingProxy::workerGlobalScopeDestroyedInternal(int32_t exitCode, + // Web Worker's 'close' event keeps 0 for that case (documented). + if (m_options.kind == WorkerOptions::Kind::Node && stoppedByParent) + exitCode = 1; ++ // Node: ERR_WORKER_OUT_OF_MEMORY always comes with exit code 1. ++ const bool stoppedByHeapLimit = m_stoppedByHeapLimit.load(std::memory_order_acquire); ++ if (stoppedByHeapLimit) ++ exitCode = 1; + + // Closing while 'close' dispatches so handlers observe threadId == -1 / !isOnline() but a + // postMessage() from inside them is still accepted and dropped (browser/Node behaviour). +@@ -806,9 +879,20 @@ void WorkerMessagingProxy::workerGlobalScopeDestroyedInternal(int32_t exitCode, + // task then finds it empty. + drainMessagesToWorkerObject(*m_scriptExecutionContext, DrainBudget::UntilEmpty); + +- if (RefPtr workerObject = m_workerObject; workerObject && workerObject->hasEventListeners(eventNames().closeEvent)) { +- auto event = CloseEvent::create(exitCode == 0, static_cast(exitCode), exitCode == 0 ? "Worker terminated normally"_s : "Worker exited abnormally"_s); +- workerObject->dispatchCloseEvent(event); ++ if (RefPtr workerObject = m_workerObject) { ++ // Node emits 'error' (with worker.resourceLimits already {}) and then 'exit' for a worker that hit its heap limit. ++ if (stoppedByHeapLimit && workerObject->hasEventListeners(eventNames().errorEvent)) { ++ ErrorEvent::Init init; ++ // An empty message makes worker_threads.ts emit `error` itself, keeping its `code`. ++ init.error = Bun::createError(m_scriptExecutionContext->globalObject(), Bun::ErrorCode::ERR_WORKER_OUT_OF_MEMORY, ++ "Worker terminated due to reaching memory limit: JS heap out of memory"_s); ++ auto event = ErrorEvent::create(eventNames().errorEvent, init, EventIsTrusted::Yes); ++ workerObject->dispatchExitEvent(event); ++ } ++ if (workerObject->hasEventListeners(eventNames().closeEvent)) { ++ auto event = CloseEvent::create(exitCode == 0, static_cast(exitCode), exitCode == 0 ? "Worker terminated normally"_s : "Worker exited abnormally"_s); ++ workerObject->dispatchExitEvent(event); ++ } + } + + releaseWorkerThread(); +diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.h b/src/jsc/bindings/webcore/WorkerMessagingProxy.h +index f2e8ea6..c517414 100644 +--- a/src/jsc/bindings/webcore/WorkerMessagingProxy.h ++++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.h +@@ -50,6 +50,7 @@ namespace WebCore { + + class Event; + class Worker; ++class WorkerHeapLimitObserver; + + // The only object shared between a Worker (parent thread, script-visible) and the thread that runs + // its global scope. Created with the Worker; outlives both the Worker object and the thread. +@@ -113,6 +114,11 @@ public: + void workerGlobalScopeDestroyed(int32_t exitCode, bool stoppedByParent); + void drainMessagesToWorkerGlobalScope(ScriptExecutionContext&); + ++ // No-op without a configured resourceLimits heap limit; a limit hit stops the thread like terminate() and reports ERR_WORKER_OUT_OF_MEMORY. ++ void installHeapLimitObserver(JSC::VM&, void* workerThread); ++ // The collections run by exit handlers and teardown must not be reported as running out of memory. ++ void disarmHeapLimitObserver() { m_heapLimitDisarmed.store(true, std::memory_order_release); } ++ + // -- Either thread --------------------------------------------------------------------------- + WorkerOptions& options() { return m_options; } + ScriptExecutionContextIdentifier workerContextIdentifier() const { return m_workerContextIdentifier; } +@@ -153,6 +159,12 @@ private: + + std::atomic m_state { State::Pending }; + ++ // The observer runs on whichever thread finished a collection (hence the atomics) and is never unregistered: this proxy outlives the heap it watches. ++ friend class WorkerHeapLimitObserver; ++ std::unique_ptr m_heapLimitObserver; ++ std::atomic m_heapLimitDisarmed { false }; ++ std::atomic m_stoppedByHeapLimit { false }; ++ + // Pending -> Running happens under this lock so a task posted while Pending is either queued here + // (and run by workerGlobalScopeStarted) or posted directly, never lost. + Lock m_pendingTasksLock; +diff --git a/src/jsc/bindings/webcore/WorkerOptions.h b/src/jsc/bindings/webcore/WorkerOptions.h +index 72532e8..de50621 100644 +--- a/src/jsc/bindings/webcore/WorkerOptions.h ++++ b/src/jsc/bindings/webcore/WorkerOptions.h +@@ -14,6 +14,36 @@ enum class WorkerEvalMode : uint8_t { + Module, + }; + ++struct WorkerResourceLimits { ++ double maxYoungGenerationSizeMb { -1 }; ++ double maxOldGenerationSizeMb { -1 }; ++ double codeRangeSizeMb { -1 }; ++ double stackSizeMb { 4 }; ++ ++ WorkerResourceLimits resolved() const ++ { ++ auto result = *this; ++ if (!(result.maxOldGenerationSizeMb > 0)) ++ result.maxOldGenerationSizeMb = 4096; ++ if (!(result.maxYoungGenerationSizeMb > 0)) ++ result.maxYoungGenerationSizeMb = 192; ++ if (!(result.codeRangeSizeMb > 0)) ++ result.codeRangeSizeMb = 0; ++ return result; ++ } ++ ++ static size_t bytes(double mb) ++ { ++ if (!(mb > 0) || !std::isfinite(mb)) ++ return 0; ++ double value = mb * 1024.0 * 1024.0; ++ if (value >= static_cast(std::numeric_limits::max())) ++ return std::numeric_limits::max(); ++ return static_cast(value); ++ } ++ ++}; ++ + struct WorkerOptions { + enum class Kind : uint8_t { + // Created by the global Worker constructor +@@ -52,6 +82,7 @@ struct WorkerOptions { + size_t execArgvRequirePreloadCount { 0 }; + WorkerEvalMode execArgvEvalMode { WorkerEvalMode::Auto }; + String evalSource; ++ WorkerResourceLimits resourceLimits; + }; + + } // namespace WebCore +diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs +index 8429f7f..e4b7e80 100644 +--- a/src/jsc/web_worker.rs ++++ b/src/jsc/web_worker.rs +@@ -234,6 +234,9 @@ unsafe extern "C" { + message: BunString, + err: JSValue, + ); ++ safe fn WebWorker__installHeapLimitObserver(proxy: *mut c_void, global: &JSGlobalObject, worker_thread: *const c_void); ++ safe fn WebWorker__disarmHeapLimitObserver(proxy: *mut c_void); ++ safe fn WebWorker__stackSize(proxy: *mut c_void) -> usize; + safe fn Bun__freeSharedHeaderBufferForThreadExit(); + // Raw FFI (no RAII guard) so `thread_main` can take the API lock and abandon + // it with the VM โ€” see the note there. +@@ -645,7 +648,11 @@ impl WebWorker { + _parent_ticket: parent_ticket, + }; + let spawn = std::thread::Builder::new() +- .stack_size(bun_threading::thread_pool::DEFAULT_THREAD_STACK_SIZE as usize) ++ .stack_size({ ++ let bytes = WebWorker__stackSize(proxy); ++ // Keep native initialization and exception handling headroom; JSC enforces the requested script stack. ++ bytes.max(bun_threading::thread_pool::DEFAULT_THREAD_STACK_SIZE as usize) ++ }) + .spawn(move || { + let start = start; + start.worker.thread_main(start.init); +@@ -722,7 +729,7 @@ impl WebWorker { + /// TerminationException in its VM at the next safepoint, wake its loop. + /// Any thread that holds a ref (the proxy) may call this. + #[unsafe(export_name = "WebWorker__requestTermination")] +- pub(crate) extern "C" fn request_termination(this: *mut WebWorker) { ++ pub(crate) extern "C" fn request_termination(this: *mut WebWorker) -> bool { + let this = bun_ptr::ParentRef::from(NonNull::new(this).expect("WebWorker FFI ptr")); + // The handle's lock is taken *before* the flag is published: a worker + // that breaks out of its loop because it saw the flag then blocks in +@@ -730,7 +737,7 @@ impl WebWorker { + // set here, instead of racing past with neither. + let handle = this.vm_handle.lock(); + if this.set_requested_terminate() { +- return; ++ return false; + } + log!("[{}] requestTermination", this.execution_context_id); + if let Some(handle) = &*handle { +@@ -743,6 +750,7 @@ impl WebWorker { + // safepoint and its loop woken. + handle.request_termination(); + } ++ true + } + + /// The parent reading this worker's loop counters for `eventLoopUtilization()`: false outside +@@ -980,6 +988,11 @@ impl WebWorker { + self.vm.set(vm); + // SAFETY: `vm` is the live VM just built on this thread. + *self.vm_handle.lock() = Some(unsafe { (*vm).handle() }); ++ WebWorker__installHeapLimitObserver( ++ self.messaging_proxy, ++ JSGlobalObject::opaque_ref(unsafe { (*vm).global }), ++ core::ptr::from_ref(self).cast(), ++ ); + + // SAFETY: `vm` is a valid heap-allocated VM ptr (checked above). + unsafe { +@@ -1276,6 +1289,9 @@ impl WebWorker { + bun_analytics::features::workers_terminated.fetch_add(1, Ordering::Relaxed); + log!("[{}] shutdown", self.execution_context_id); + ++ // The exit handlers' and teardown's collections must not report out of memory. ++ WebWorker__disarmHeapLimitObserver(self.messaging_proxy); ++ + // worker-thread only field; no other thread reads `arena`. + let mut arena = self.arena.replace(None); + let env_loader = self.worker_env_loader.replace(core::ptr::null_mut()); +diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts +index 46971b5..0389f69 100644 +--- a/test/js/node/worker_threads/worker_threads.test.ts ++++ b/test/js/node/worker_threads/worker_threads.test.ts +@@ -3900,3 +3900,82 @@ test("getHeapStatistics includes array storage allocated since the last collecti + await worker.terminate(); + } + }); ++ ++describe("resourceLimits", () => { ++ async function runLimitedWorker(limits: object, body: string) { ++ await using child = Bun.spawn({ ++ cmd: [bunExe(), "-e", ` ++ const {Worker,resourceLimits}=require('node:worker_threads'); ++ const w=new Worker(${JSON.stringify("const {parentPort,resourceLimits}=require('node:worker_threads');parentPort.postMessage({limits:resourceLimits});parentPort.once('message',()=>{" + body + "});")},{eval:true,resourceLimits:${JSON.stringify(limits)}}); ++ const result={main:resourceLimits,before:w.resourceLimits,events:[],messages:[]}; ++ w.on('online',()=>{result.online=w.resourceLimits;}); ++ w.on('message',m=>{result.messages.push(m);if(m.limits)w.postMessage('go');}); ++ 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, ++ stdout: "pipe", ++ stderr: "pipe", ++ }); ++ const [stdout, stderr, exitCode] = await Promise.all([child.stdout.text(), child.stderr.text(), child.exited]); ++ expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); ++ return JSON.parse(stdout); ++ } ++ ++ test("reports requested values in the parent and worker, then clears on exit", async () => { ++ const limits = { maxYoungGenerationSizeMb: 16, maxOldGenerationSizeMb: 64, codeRangeSizeMb: 32, stackSizeMb: 2 }; ++ expect(await runLimitedWorker(limits, "")).toEqual({ ++ main: {}, before: limits, online: limits, messages: [{ limits }], events: ["exit"], exit: 0, after: {}, ++ }); ++ }); ++ ++ 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');"); ++ expect({ events: result.events, error: result.error, exit: result.exit, after: result.after, messages: result.messages.length }).toEqual({ ++ events: ["error", "exit"], ++ error: { name: "Error", code: "ERR_WORKER_OUT_OF_MEMORY", message: "Worker terminated due to reaching memory limit: JS heap out of memory", limits: {} }, ++ exit: 1, after: {}, messages: 1, ++ }); ++ expect((await runLimitedWorker({ maxOldGenerationSizeMb: 64 }, "parentPort.postMessage('alive');")).messages.at(-1)).toBe("alive"); ++ }); ++ ++ test.each(["large", "small", "materialized", "shared"])("excludes %s external backing stores", async kind => { ++ const allocation = kind === "small" ++ ? "for(let i=0;i<65536;i++)held.push(new Uint8Array(2048).fill(7));" ++ : kind === "materialized" ++ ? "for(let i=0;i<32;i++)held.push(new Uint8Array(new ArrayBuffer(4*1024*1024)).fill(7));" ++ : kind === "shared" ++ ? "for(let i=0;i<32;i++)held.push(new Uint8Array(new SharedArrayBuffer(4*1024*1024)).fill(7));" ++ : "for(let i=0;i<32;i++)held.push(new Uint8Array(4*1024*1024).fill(7));"; ++ const result = await runLimitedWorker({ maxOldGenerationSizeMb: 32, maxYoungGenerationSizeMb: 4 }, ++ "globalThis.held=[];" + allocation + "Bun.gc(true);parentPort.postMessage(held.reduce((n,a)=>n+a.byteLength,0));"); ++ expect({ events: result.events, exit: result.exit, bytes: result.messages.at(-1) }).toEqual({ events: ["exit"], exit: 0, bytes: 128 * 1024 * 1024 }); ++ }); ++ ++ test("external allocations do not hide managed heap growth", async () => { ++ const result = await runLimitedWorker({ maxOldGenerationSizeMb: 32, maxYoungGenerationSizeMb: 4 }, ++ "globalThis.held=[new Uint8Array(128*1024*1024).fill(7)];Bun.gc(true);for(let i=0;i<64;i++)held.push(new Array(1024*1024).fill(i));"); ++ expect({ code: result.error?.code, exit: result.exit }).toEqual({ code: "ERR_WORKER_OUT_OF_MEMORY", exit: 1 }); ++ }); ++ ++ 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');"); ++ expect({ events: result.events, exit: result.exit, value: result.messages.at(-1) }).toEqual({ events: ["exit"], exit: 0, value: "retained" }); ++ }); ++ ++ test("stackSizeMb changes the worker stack capacity", async () => { ++ const body = "let depth=0;function recur(){depth++;return recur()+1;}try{recur();}catch(e){parentPort.postMessage({depth,name:e.name});}"; ++ const small = await runLimitedWorker({ stackSizeMb: 1 }, body); ++ const large = await runLimitedWorker({ stackSizeMb: 4 }, body); ++ expect({ small: small.exit, large: large.exit, smallError: small.messages.at(-1).name, largeError: large.messages.at(-1).name }).toEqual({ small: 0, large: 0, smallError: "RangeError", largeError: "RangeError" }); ++ expect(large.messages.at(-1).depth).toBeGreaterThan(small.messages.at(-1).depth * 2); ++ }); ++ ++ test("a vm timeout remains usable before a worker heap OOM", async () => { ++ const result = await runLimitedWorker({ maxOldGenerationSizeMb: 32, maxYoungGenerationSizeMb: 4 }, ++ "try{require('node:vm').runInNewContext('while(true){}',{},{timeout:10});}catch(e){parentPort.postMessage(e.code);}globalThis.held=[];for(let i=0;i<64;i++)held.push(new Array(1024*1024).fill(i));"); ++ expect({ timeout: result.messages[1], code: result.error?.code, exit: result.exit }).toEqual({ timeout: "ERR_SCRIPT_EXECUTION_TIMEOUT", code: "ERR_WORKER_OUT_OF_MEMORY", exit: 1 }); ++ }); ++}); +-- +2.54.0 (Apple Git-157) + diff --git a/.github/openclaw/qualification/patches/004-bun-limit-readiness.patch b/.github/openclaw/qualification/patches/004-bun-limit-readiness.patch new file mode 100644 index 0000000000000..d7b48e9b237e2 --- /dev/null +++ b/.github/openclaw/qualification/patches/004-bun-limit-readiness.patch @@ -0,0 +1,77 @@ +From 47af965940cc15417f96de2b457ced89e2c58bfc Mon Sep 17 00:00:00 2001 +From: Peter Steinberger +Date: Sat, 3 Oct 2026 08:53:59 -0700 +Subject: [PATCH] fix(worker): publish resolved limits before the online event + +--- + src/jsc/bindings/webcore/JSWorker.cpp | 2 +- + src/jsc/bindings/webcore/WorkerMessagingProxy.cpp | 1 + + src/jsc/bindings/webcore/WorkerMessagingProxy.h | 2 ++ + test/js/node/worker_threads/worker_threads.test.ts | 6 ++++++ + 4 files changed, 10 insertions(+), 1 deletion(-) + +diff --git a/src/jsc/bindings/webcore/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp +index 884f644..83aae76 100644 +--- a/src/jsc/bindings/webcore/JSWorker.cpp ++++ b/src/jsc/bindings/webcore/JSWorker.cpp +@@ -646,7 +646,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsWorker_resourceLimitsGetter, (JSGlobalObject * lexica + if (worker.hasExited()) + return JSValue::encode(constructEmptyObject(lexicalGlobalObject)); + auto limits = worker.contextProxy().options().resourceLimits; +- return JSValue::encode(createResourceLimitsObject(lexicalGlobalObject, worker.contextProxy().isOnline() ? limits.resolved() : limits)); ++ return JSValue::encode(createResourceLimitsObject(lexicalGlobalObject, worker.contextProxy().resourceLimitsReady() ? limits.resolved() : limits)); + } + + /* Hash table for prototype */ +diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp +index 128c798..b96d291 100644 +--- a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp ++++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp +@@ -149,6 +149,7 @@ void WorkerMessagingProxy::installHeapLimitObserver(JSC::VM& vm, void* workerThr + ASSERT(!m_heapLimitObserver); + m_heapLimitObserver = makeUnique(*this, vm, workerThread); + vm.heap.addObserver(m_heapLimitObserver.get()); ++ m_resourceLimitsReady.store(true, std::memory_order_release); + } + + extern "C" size_t WebWorker__stackSize(WorkerMessagingProxy* proxy) +diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.h b/src/jsc/bindings/webcore/WorkerMessagingProxy.h +index c517414..5eb1d9b 100644 +--- a/src/jsc/bindings/webcore/WorkerMessagingProxy.h ++++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.h +@@ -116,6 +116,7 @@ public: + + // No-op without a configured resourceLimits heap limit; a limit hit stops the thread like terminate() and reports ERR_WORKER_OUT_OF_MEMORY. + void installHeapLimitObserver(JSC::VM&, void* workerThread); ++ bool resourceLimitsReady() const { return m_resourceLimitsReady.load(std::memory_order_acquire); } + // The collections run by exit handlers and teardown must not be reported as running out of memory. + void disarmHeapLimitObserver() { m_heapLimitDisarmed.store(true, std::memory_order_release); } + +@@ -164,6 +165,7 @@ private: + std::unique_ptr m_heapLimitObserver; + std::atomic m_heapLimitDisarmed { false }; + std::atomic m_stoppedByHeapLimit { false }; ++ std::atomic m_resourceLimitsReady { false }; + + // Pending -> Running happens under this lock so a task posted while Pending is either queued here + // (and run by workerGlobalScopeStarted) or posted directly, never lost. +diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts +index 0389f69..643f0ff 100644 +--- a/test/js/node/worker_threads/worker_threads.test.ts ++++ b/test/js/node/worker_threads/worker_threads.test.ts +@@ -3929,6 +3929,12 @@ describe("resourceLimits", () => { + }); + }); + ++ test.each([{}, { maxOldGenerationSizeMb: "32", maxYoungGenerationSizeMb: true, stackSizeMb: null }])("resolves defaults by online and ignores non-number fields: %j", async requested => { ++ const result = await runLimitedWorker(requested, ""); ++ const limits = { maxYoungGenerationSizeMb: 192, maxOldGenerationSizeMb: 4096, codeRangeSizeMb: 0, stackSizeMb: 4 }; ++ expect({ online: result.online, inside: result.messages[0].limits, after: result.after, exit: result.exit }).toEqual({ online: limits, inside: limits, after: {}, exit: 0 }); ++ }); ++ + 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');"); +-- +2.54.0 (Apple Git-157) + diff --git a/.github/openclaw/qualification/patches/005-bun-limit-initialization-gc.patch b/.github/openclaw/qualification/patches/005-bun-limit-initialization-gc.patch new file mode 100644 index 0000000000000..0c9942437aa1c --- /dev/null +++ b/.github/openclaw/qualification/patches/005-bun-limit-initialization-gc.patch @@ -0,0 +1,35 @@ +From 14bf307d925cb1b06b2cdd27416ec41cfe5bfc7e Mon Sep 17 00:00:00 2001 +From: Peter Steinberger +Date: Sat, 3 Oct 2026 08:57:16 -0700 +Subject: [PATCH] fix(worker): finish initialization GC before installing + limits + +--- + src/jsc/bindings/webcore/WorkerMessagingProxy.cpp | 4 ++++ + 1 file changed, 4 insertions(+) + +diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp +index b96d291..73f1c73 100644 +--- a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp ++++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp +@@ -40,6 +40,7 @@ + #include "Worker.h" + #include "ZigGlobalObject.h" + #include ++#include + #include + #include + #include +@@ -143,6 +144,9 @@ void WorkerMessagingProxy::installHeapLimitObserver(JSC::VM& vm, void* workerThr + { + if (m_options.kind != WorkerOptions::Kind::Node) + return; ++ JSC::JSLockHolder lock(vm); ++ // Finish initialization marking before publishing limits to parallel visitors. ++ vm.heap.collectNow(JSC::Sync, JSC::CollectionScope::Full); + auto limits = m_options.resourceLimits.resolved(); + vm.setWorkerStackUsage(WorkerResourceLimits::bytes(limits.stackSizeMb)); + vm.heap.setWorkerHeapLimits(WorkerResourceLimits::bytes(limits.maxOldGenerationSizeMb), WorkerResourceLimits::bytes(limits.maxYoungGenerationSizeMb)); +-- +2.54.0 (Apple Git-157) + diff --git a/.github/openclaw/qualification/patches/006-bun-node-worker-boundary.patch b/.github/openclaw/qualification/patches/006-bun-node-worker-boundary.patch new file mode 100644 index 0000000000000..ea42e60bb6fff --- /dev/null +++ b/.github/openclaw/qualification/patches/006-bun-node-worker-boundary.patch @@ -0,0 +1,149 @@ +From 458e039deb4a14d3dcad15d77fda535fbe4746c3 Mon Sep 17 00:00:00 2001 +From: Peter Steinberger +Date: Sat, 3 Oct 2026 09:03:40 -0700 +Subject: [PATCH] fix(worker): limit resource configuration to Node workers + +--- + packages/bun-types/bun.d.ts | 19 +--------- + src/js/node/worker_threads.ts | 1 + + src/jsc/bindings/webcore/JSWorker.cpp | 54 ++++++++++++++------------- + src/jsc/bindings/webcore/Worker.cpp | 2 +- + 4 files changed, 31 insertions(+), 45 deletions(-) + +diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts +index 48beeef..1769d75 100644 +--- a/packages/bun-types/bun.d.ts ++++ b/packages/bun-types/bun.d.ts +@@ -704,17 +704,7 @@ declare module "bun" { + * @default true + */ + // trackUnmanagedFds?: boolean; +- +- /** +- * Resource limits for the worker's JS engine, in megabytes, with the same +- * shape as Node.js. `maxOldGenerationSizeMb` caps the worker's heap: a +- * worker whose heap is still above it after a full garbage collection is +- * terminated and emits an `ERR_WORKER_OUT_OF_MEMORY` error event. The +- * young-generation option sizes the nursery. `stackSizeMb` sets the JS +- * stack capacity. `codeRangeSizeMb` is reported without a per-worker +- * executable-memory reservation. These limits apply to Node workers. +- */ +- resourceLimits?: import("node:worker_threads").ResourceLimits; ++ // resourceLimits?: import("worker_threads").ResourceLimits; + + /** + * An array of module specifiers to preload in the worker. +@@ -785,13 +775,6 @@ declare module "bun" { + * This value is unique for each `Worker` instance inside a single process. + */ + threadId: number; +- +- /** +- * The JS engine resource constraints this worker was created with, or +- * an empty object once the worker has stopped. Inside the worker thread, +- * it is available as `require('node:worker_threads').resourceLimits`. +- */ +- readonly resourceLimits: import("node:worker_threads").ResourceLimits; + } + + interface Env { +diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts +index 1d5e152..e6b321c 100644 +--- a/src/js/node/worker_threads.ts ++++ b/src/js/node/worker_threads.ts +@@ -3,6 +3,7 @@ declare const self: Omit & { + onmessageerror: ((this: typeof globalThis, ev: MessageEvent) => unknown) | null; + }; + type WebWorker = InstanceType & { ++ readonly resourceLimits: Record; + getHeapSnapshot(options: unknown): Promise; + getHeapStatistics(): Promise>; + startCpuProfileInternal(): Promise; +diff --git a/src/jsc/bindings/webcore/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp +index 83aae76..6e3775a 100644 +--- a/src/jsc/bindings/webcore/JSWorker.cpp ++++ b/src/jsc/bindings/webcore/JSWorker.cpp +@@ -481,31 +481,33 @@ template<> __attribute__((minsize)) JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES + RETURN_IF_EXCEPTION(throwScope, {}); + } + +- JSValue resourceLimitsValue = optionsObject->getIfPropertyExists(lexicalGlobalObject, Identifier::fromString(vm, "resourceLimits"_s)); +- RETURN_IF_EXCEPTION(throwScope, {}); +- // As in Node's parseResourceLimits: non-objects, functions and non-number fields are ignored. +- if (resourceLimitsValue && resourceLimitsValue.isObject() && !resourceLimitsValue.isCallable()) { +- auto* limitsObject = asObject(resourceLimitsValue); +- WorkerResourceLimits limits; +- auto readLimit = [&](ASCIILiteral key, double& out, std::optional floor = std::nullopt) -> bool { +- JSValue value = limitsObject->getIfPropertyExists(lexicalGlobalObject, Identifier::fromString(vm, key)); +- if (throwScope.exception()) +- return false; +- if (value && value.isNumber()) +- out = floor ? std::max(value.asNumber(), *floor) : value.asNumber(); +- return true; +- }; +- if (!readLimit("maxYoungGenerationSizeMb"_s, limits.maxYoungGenerationSizeMb)) return {}; +- // Node floors this one at 2 MB (MathMax(value, 2)). +- if (!readLimit("maxOldGenerationSizeMb"_s, limits.maxOldGenerationSizeMb, 2.0)) return {}; +- if (!readLimit("codeRangeSizeMb"_s, limits.codeRangeSizeMb)) return {}; +- if (!readLimit("stackSizeMb"_s, limits.stackSizeMb)) return {}; +- // Node replaces a non-positive stack size with its 4 MB default. +- if (!(limits.stackSizeMb > 0)) +- limits.stackSizeMb = 4; +- else +- limits.stackSizeMb = std::max(limits.stackSizeMb, 192.0 / 1024.0); +- options.resourceLimits = limits; ++ if (options.kind == WorkerOptions::Kind::Node) { ++ JSValue resourceLimitsValue = optionsObject->getIfPropertyExists(lexicalGlobalObject, Identifier::fromString(vm, "resourceLimits"_s)); ++ RETURN_IF_EXCEPTION(throwScope, {}); ++ // As in Node's parseResourceLimits: non-objects, functions and non-number fields are ignored. ++ if (resourceLimitsValue && resourceLimitsValue.isObject() && !resourceLimitsValue.isCallable()) { ++ auto* limitsObject = asObject(resourceLimitsValue); ++ WorkerResourceLimits limits; ++ auto readLimit = [&](ASCIILiteral key, double& out, std::optional floor = std::nullopt) -> bool { ++ JSValue value = limitsObject->getIfPropertyExists(lexicalGlobalObject, Identifier::fromString(vm, key)); ++ if (throwScope.exception()) ++ return false; ++ if (value && value.isNumber()) ++ out = floor ? std::max(value.asNumber(), *floor) : value.asNumber(); ++ return true; ++ }; ++ if (!readLimit("maxYoungGenerationSizeMb"_s, limits.maxYoungGenerationSizeMb)) return {}; ++ // Node floors this one at 2 MB (MathMax(value, 2)). ++ if (!readLimit("maxOldGenerationSizeMb"_s, limits.maxOldGenerationSizeMb, 2.0)) return {}; ++ if (!readLimit("codeRangeSizeMb"_s, limits.codeRangeSizeMb)) return {}; ++ if (!readLimit("stackSizeMb"_s, limits.stackSizeMb)) return {}; ++ // Node replaces a non-positive stack size with its 4 MB default. ++ if (!(limits.stackSizeMb > 0)) ++ limits.stackSizeMb = 4; ++ else ++ limits.stackSizeMb = std::max(limits.stackSizeMb, 192.0 / 1024.0); ++ options.resourceLimits = limits; ++ } + } + + JSValue execArgvValue = optionsObject->getIfPropertyExists(lexicalGlobalObject, Identifier::fromString(vm, "execArgv"_s)); +@@ -643,7 +645,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsWorker_resourceLimitsGetter, (JSGlobalObject * lexica + + auto& worker = castedThis->wrapped(); + // Node reports {} once the thread has exited (the proxy is already Closing inside the OOM 'error' handler). +- if (worker.hasExited()) ++ if (worker.hasExited() || worker.contextProxy().options().kind != WorkerOptions::Kind::Node) + return JSValue::encode(constructEmptyObject(lexicalGlobalObject)); + auto limits = worker.contextProxy().options().resourceLimits; + return JSValue::encode(createResourceLimitsObject(lexicalGlobalObject, worker.contextProxy().resourceLimitsReady() ? limits.resolved() : limits)); +diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp +index c1e6b69..cd49e10 100644 +--- a/src/jsc/bindings/webcore/Worker.cpp ++++ b/src/jsc/bindings/webcore/Worker.cpp +@@ -434,7 +434,7 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) + RETURN_IF_EXCEPTION(scope, {}); + array->putDirectIndex(globalObject, 18, JSFunction::create(vm, globalObject, 1, "workerEventLoopUtilization"_s, jsFunctionWorkerEventLoopUtilization, ImplementationVisibility::Public, NoIntrinsic)); + RETURN_IF_EXCEPTION(scope, {}); +- JSObject* resourceLimits = proxy ? createResourceLimitsObject(globalObject, proxy->options().resourceLimits.resolved()) : constructEmptyObject(globalObject); ++ JSObject* resourceLimits = isNodeWorker ? createResourceLimitsObject(globalObject, proxy->options().resourceLimits.resolved()) : constructEmptyObject(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + array->putDirectIndex(globalObject, 19, resourceLimits); + RETURN_IF_EXCEPTION(scope, {}); +-- +2.54.0 (Apple Git-157) + diff --git a/.github/openclaw/qualification/patches/007-bun-node-getter-semantics.patch b/.github/openclaw/qualification/patches/007-bun-node-getter-semantics.patch new file mode 100644 index 0000000000000..da0682e60de37 --- /dev/null +++ b/.github/openclaw/qualification/patches/007-bun-node-getter-semantics.patch @@ -0,0 +1,92 @@ +From 14ed320ab52ac480f9ab1180cf041971052e6915 Mon Sep 17 00:00:00 2001 +From: Peter Steinberger +Date: Sat, 3 Oct 2026 09:10:07 -0700 +Subject: [PATCH] fix(worker): match Node resource-limit getter evaluation + +--- + src/jsc/bindings/webcore/JSWorker.cpp | 18 +++++++--- + .../worker_threads/worker_threads.test.ts | 33 +++++++++++++++++++ + 2 files changed, 47 insertions(+), 4 deletions(-) + +diff --git a/src/jsc/bindings/webcore/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp +index 6e3775a..61f6401 100644 +--- a/src/jsc/bindings/webcore/JSWorker.cpp ++++ b/src/jsc/bindings/webcore/JSWorker.cpp +@@ -489,16 +489,26 @@ template<> __attribute__((minsize)) JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES + auto* limitsObject = asObject(resourceLimitsValue); + WorkerResourceLimits limits; + auto readLimit = [&](ASCIILiteral key, double& out, std::optional floor = std::nullopt) -> bool { +- JSValue value = limitsObject->getIfPropertyExists(lexicalGlobalObject, Identifier::fromString(vm, key)); ++ auto identifier = Identifier::fromString(vm, key); ++ JSValue value = limitsObject->get(lexicalGlobalObject, identifier); + if (throwScope.exception()) + return false; +- if (value && value.isNumber()) +- out = floor ? std::max(value.asNumber(), *floor) : value.asNumber(); ++ if (!value.isNumber()) ++ return true; ++ // Node checks typeof first, then reads again for MathMax/Float64Array conversion. ++ // https://github.com/nodejs/node/blob/v24.21.0/lib/internal/worker.js#L654-L667 ++ value = limitsObject->get(lexicalGlobalObject, identifier); ++ if (throwScope.exception()) ++ return false; ++ double number = value.toNumber(lexicalGlobalObject); ++ if (throwScope.exception()) ++ return false; ++ out = floor ? std::max(number, *floor) : number; + return true; + }; +- if (!readLimit("maxYoungGenerationSizeMb"_s, limits.maxYoungGenerationSizeMb)) return {}; + // Node floors this one at 2 MB (MathMax(value, 2)). + if (!readLimit("maxOldGenerationSizeMb"_s, limits.maxOldGenerationSizeMb, 2.0)) return {}; ++ if (!readLimit("maxYoungGenerationSizeMb"_s, limits.maxYoungGenerationSizeMb)) return {}; + if (!readLimit("codeRangeSizeMb"_s, limits.codeRangeSizeMb)) return {}; + if (!readLimit("stackSizeMb"_s, limits.stackSizeMb)) return {}; + // Node replaces a non-positive stack size with its 4 MB default. +diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts +index 643f0ff..37fc1a6 100644 +--- a/test/js/node/worker_threads/worker_threads.test.ts ++++ b/test/js/node/worker_threads/worker_threads.test.ts +@@ -3902,6 +3902,39 @@ test("getHeapStatistics includes array storage allocated since the last collecti + }); + + describe("resourceLimits", () => { ++ test("reads numeric getters in Node order and converts the second value", async () => { ++ const calls: string[] = []; ++ const values = { maxOldGenerationSizeMb: 64, maxYoungGenerationSizeMb: 16, codeRangeSizeMb: 32, stackSizeMb: 2 }; ++ const limits = {}; ++ for (const key of Object.keys(values)) { ++ let count = 0; ++ Object.defineProperty(limits, key, { get() { calls.push(key); return ++count === 1 ? values[key] : String(values[key]); } }); ++ } ++ const worker = new Worker("", { eval: true, resourceLimits: limits }); ++ try { ++ expect(calls).toEqual(Object.keys(values).flatMap(key => [key, key])); ++ expect(worker.resourceLimits).toEqual(values); ++ } finally { ++ await worker.terminate(); ++ } ++ }); ++ ++ test("propagates a second getter exception unchanged", async () => { ++ const error = new Error("second resource-limit getter"); ++ let reads = 0; ++ let worker: Worker | undefined; ++ let caught: unknown; ++ try { ++ worker = new Worker("", { eval: true, resourceLimits: { get maxOldGenerationSizeMb() { if (++reads === 2) throw error; return 64; } } }); ++ } catch (value) { ++ caught = value; ++ } finally { ++ await worker?.terminate(); ++ } ++ expect(caught).toBe(error); ++ expect(reads).toBe(2); ++ }); ++ + async function runLimitedWorker(limits: object, body: string) { + await using child = Bun.spawn({ + cmd: [bunExe(), "-e", ` +-- +2.54.0 (Apple Git-157) + diff --git a/.github/openclaw/qualification/run-fork-ci.sh b/.github/openclaw/qualification/run-fork-ci.sh new file mode 100644 index 0000000000000..2d5b9388cbe52 --- /dev/null +++ b/.github/openclaw/qualification/run-fork-ci.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail +test "$(uname -sm)" = 'Linux x86_64' +: "${BUN_CHECKOUT:?set Bun checkout}" +: "${CANDIDATE_BUN:?set an absolute candidate binary}" +: "${RESULT_DIR:?set a fresh absolute evidence directory}" +case "$RESULT_DIR" in /*) ;; *) exit 2;; esac +case "$CANDIDATE_BUN" in /*) ;; *) exit 2;; esac +test ! -e "$RESULT_DIR" +umask 077 +mkdir -p "$RESULT_DIR" "$RESULT_DIR/home" "$RESULT_DIR/state" "$RESULT_DIR/tmp" "$RESULT_DIR/bin" +chmod 700 "$RESULT_DIR/home" "$RESULT_DIR/state" "$RESULT_DIR/tmp" +stat -c '%a %n' "$RESULT_DIR/home" "$RESULT_DIR/state" "$RESULT_DIR/tmp" > "$RESULT_DIR/private-directory-modes.txt" +umask 022 +umask > "$RESULT_DIR/test-umask.txt" +ln -s "$CANDIDATE_BUN" "$RESULT_DIR/bin/bun" +export PATH="$RESULT_DIR/bin:$PATH" HOME="$RESULT_DIR/home" OPENCLAW_STATE_DIR="$RESULT_DIR/state" TMPDIR="$RESULT_DIR/tmp" +export CI=1 PUPPETEER_SKIP_DOWNLOAD=1 OPENCLAW_CI_PLATFORM=linux +unset PR_BASE_SHA PR_HEAD_SHA GITHUB_EVENT_NAME +unset BUN_WEBKIT_ARTIFACT_MANIFEST BUN_BUILD_PREFETCH_DIR BUN_BUILD_CACHE_DIR +cd "$BUN_CHECKOUT" +git rev-parse HEAD > "$RESULT_DIR/bun-sha.txt" +sha256sum "$CANDIDATE_BUN" > "$RESULT_DIR/bun.sha256" +"$CANDIDATE_BUN" --revision > "$RESULT_DIR/bun-revision.txt" +"$CANDIDATE_BUN" scripts/openclaw-ci/tests.ts select > "$RESULT_DIR/select.log" 2>&1 +cp build/openclaw-ci/selected.json "$RESULT_DIR/nightly-selected.json" +# Include the fork's own engine-upgrade coverage added by the upstream sync. +node --input-type=module - <<'JS' >> "$RESULT_DIR/select.log" 2>&1 +import { selectTests } from './scripts/openclaw-ci/tests.ts'; +import { execFileSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +const tracked = execFileSync('git', ['ls-files', '-z', 'test'], { encoding: 'utf8' }).split('\0').filter(Boolean); +const selected = selectTests(['scripts/build/deps/webkit.ts'], tracked, true, 'linux'); +writeFileSync('build/openclaw-ci/selected.json', JSON.stringify(selected, null, 2) + '\n'); +console.log(JSON.stringify({ selectorInput: { changed: ['scripts/build/deps/webkit.ts'], nightly: true, platform: 'linux' }, files: selected.length })); +JS +cp build/openclaw-ci/selected.json "$RESULT_DIR/selected.json" +# Do not reuse a report left by a prior variant if this run crashes. +rm -f build/openclaw-ci/results.json +set +e +/usr/bin/time -v -o "$RESULT_DIR/test-time.txt" \ + "$CANDIDATE_BUN" scripts/openclaw-ci/tests.ts test > "$RESULT_DIR/tests.log" 2>&1 +rc=$? +set -e +printf '%s\n' "$rc" > "$RESULT_DIR/exit-code.txt" +if test -f build/openclaw-ci/results.json; then + cp build/openclaw-ci/results.json "$RESULT_DIR/results.json" +fi +exit "$rc" diff --git a/.github/openclaw/qualification/segmenter.js b/.github/openclaw/qualification/segmenter.js new file mode 100644 index 0000000000000..0cff16a0fe608 --- /dev/null +++ b/.github/openclaw/qualification/segmenter.js @@ -0,0 +1,32 @@ +function shouldBe(actual, expected) { if (actual !== expected) throw new Error(JSON.stringify({actual, expected})); } +// containing() must agree with iteration at both halves of surrogate pairs. +{ + const inputs = [ + "a๐Ÿ˜€b", + "Hello, world! ๐Ÿ‘๐Ÿฝ x", + "a๐Ÿ‡ฏ๐Ÿ‡ตb", + "a๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆb", + "Hi. ๐Ÿ‘ Bye.", + "๐Ÿ‘๐Ÿฝ", + "x\ud83d", + "abc", + ]; + for (const granularity of ["grapheme", "word", "sentence"]) { + for (const input of inputs) { + const segments = new Intl.Segmenter("en", { granularity }).segment(input); + const expected = Array.from(segments); + for (const direction of [1, -1]) { + for (let index = direction > 0 ? 0 : input.length - 1; index >= 0 && index < input.length; index += direction) { + const result = expected.find(segment => segment.index <= index && index < segment.index + segment.segment.length); + const actual = segments.containing(index); + shouldBe(actual.segment, result.segment); + shouldBe(actual.index, result.index); + shouldBe(actual.input, input); + shouldBe(actual.isWordLike, result.isWordLike); + } + } + shouldBe(segments.containing(-1), undefined); + shouldBe(segments.containing(input.length), undefined); + } + } +} diff --git a/.github/openclaw/qualification/selected.json b/.github/openclaw/qualification/selected.json new file mode 100644 index 0000000000000..4fe4f90ca6946 --- /dev/null +++ b/.github/openclaw/qualification/selected.json @@ -0,0 +1,46 @@ +[ + "test/bundler/compile-node-compile-cache.test.ts", + "test/cli/run/run-process-env.test.ts", + "test/js/bun/jsc/bun-jsc.test.ts", + "test/js/bun/jsc/webkit-upgrade-3722912f.test.ts", + "test/js/bun/jsc/webkit-upgrade-6b879687ee.test.ts", + "test/js/bun/jsc/webkit-upgrade-7b485a76e9.test.ts", + "test/js/bun/jsc/webkit-upgrade-8c4fd56347.test.ts", + "test/js/bun/jsc/webkit-upgrade-9b02218df6.test.ts", + "test/js/bun/jsc/webkit-upgrade-ccdcb8a026.test.ts", + "test/js/bun/jsc/webkit-upgrade-df289ce551.test.ts", + "test/js/bun/plugin/plugin-resolved-key.test.ts", + "test/js/bun/plugin/plugins.test.ts", + "test/js/bun/sqlite/column-types.test.js", + "test/js/bun/sqlite/sqlite.test.js", + "test/js/node/child_process/child-process-exec.test.ts", + "test/js/node/child_process/child-process-stdio.test.js", + "test/js/node/child_process/child_process.test.ts", + "test/js/node/child_process/child_process_ipc.test.js", + "test/js/node/fs/fs.test.ts", + "test/js/node/fs/promises.test.js", + "test/js/node/http/node-http-server-abort-events.test.ts", + "test/js/node/http/node-http-server-close-drain.test.ts", + "test/js/node/http/node-http-server-socket-end-drain.test.ts", + "test/js/node/http/node-http.test.ts", + "test/js/node/module/node-module-module.test.js", + "test/js/node/module/register-hooks-builtin-urls.test.ts", + "test/js/node/module/register-hooks-virtual-urls.test.ts", + "test/js/node/module/require-extensions.test.ts", + "test/js/node/net/node-net-allowHalfOpen.test.js", + "test/js/node/net/node-net-server.test.ts", + "test/js/node/net/node-net.test.ts", + "test/js/node/process/process.test.js", + "test/js/node/tls/node-tls-connect.test.ts", + "test/js/node/tls/node-tls-server.test.ts", + "test/js/node/tls/node-tls-wrapped-socket-close.test.ts", + "test/js/node/vm/script-leak.test.ts", + "test/js/node/vm/sourcetextmodule-leak.test.ts", + "test/js/node/vm/sourcetextmodule-link-gc.test.ts", + "test/js/node/vm/vm-script-fetcher-leak.test.ts", + "test/js/node/vm/vm-sourceUrl.test.ts", + "test/js/node/vm/vm.test.ts", + "test/js/node/worker_threads/worker-transfer-list.test.ts", + "test/js/node/worker_threads/worker_threads.test.ts", + "test/js/web/intl/intl.test.ts" +] diff --git a/.github/openclaw/qualification/stage-local-artifact.py b/.github/openclaw/qualification/stage-local-artifact.py new file mode 100644 index 0000000000000..75622d3035678 --- /dev/null +++ b/.github/openclaw/qualification/stage-local-artifact.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Stage a built WebKit tarball in a private, checksum-verified Bun prefetch cache.""" +import argparse +import hashlib +import json +import shutil +from pathlib import Path + +parser = argparse.ArgumentParser() +parser.add_argument('archive', type=Path) +parser.add_argument('--version', required=True) +parser.add_argument('--output', required=True, type=Path) +args = parser.parse_args() +with args.archive.open('rb') as stream: + digest = hashlib.file_digest(stream, 'sha256').hexdigest() +# .invalid makes a cache miss fail rather than silently downloading a different build. +name = args.archive.name +url = f'https://w104.invalid/{args.version}/{digest}/{name}' +cache = args.output / 'prefetch' / 'by-url' +cache.mkdir(parents=True, exist_ok=True) +key = hashlib.sha256(url.encode()).hexdigest()[:32] +shutil.copyfile(args.archive, cache / key) +manifest = {'version': args.version, 'artifacts': {name: {'url': url, 'sha256': digest}}} +(args.output / 'artifacts.json').write_text(json.dumps(manifest, indent=2) + '\n') +print(json.dumps({'manifest': str((args.output / 'artifacts.json').resolve()), + 'prefetch': str((args.output / 'prefetch').resolve()), 'sha256': digest})) diff --git a/.github/openclaw/qualification/upstream-artifacts.json b/.github/openclaw/qualification/upstream-artifacts.json new file mode 100644 index 0000000000000..6e1e64582b67e --- /dev/null +++ b/.github/openclaw/qualification/upstream-artifacts.json @@ -0,0 +1,173 @@ +{ + "version": "1600131e46b5af48bbda3559af8d8a3327230b6e", + "artifacts": { + "bun-webkit-freebsd-amd64-debug.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-freebsd-amd64-debug.tar.gz", + "sha256": "d7500dd58ba14be31e24346af6b40880e14b0b834fc788da66c9779f65a0c6b4" + }, + "bun-webkit-freebsd-amd64-lto.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-freebsd-amd64-lto.tar.gz", + "sha256": "5a5183a3e8e08321c05df5cbf6811cfa8f64be8096bd7767f132623d88ad64b2" + }, + "bun-webkit-freebsd-amd64.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-freebsd-amd64.tar.gz", + "sha256": "1a63633b67e48073a33e2a90aa2d39a1a3abdb18774e31a5c277fc4891367205" + }, + "bun-webkit-freebsd-arm64-debug.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-freebsd-arm64-debug.tar.gz", + "sha256": "150005639bde9f9a2b9604b4f3df343781c7447caac78d13ae2587e6883726b9" + }, + "bun-webkit-freebsd-arm64-lto.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-freebsd-arm64-lto.tar.gz", + "sha256": "027fea2d2cb393ae0bb2ba8d171de34efdecc8f1814973cd8ba1865480a689d2" + }, + "bun-webkit-freebsd-arm64.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-freebsd-arm64.tar.gz", + "sha256": "9108a5b62485a32cdbe2dddb1c73eb2a1f05fdb9d05e756ff639000f18036e45" + }, + "bun-webkit-linux-amd64-android-debug.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-amd64-android-debug.tar.gz", + "sha256": "eeb4544ced5b5154a3ebf0b9647a1e3fe281ac4d2a9674203780ab4b7b580221" + }, + "bun-webkit-linux-amd64-android-lto.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-amd64-android-lto.tar.gz", + "sha256": "3ca2749cc24e5e7345a13df7af524a91bd115324d05447995f72dcd3dcdfa753" + }, + "bun-webkit-linux-amd64-android.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-amd64-android.tar.gz", + "sha256": "35fb8236665bbdb3b29856326b6e76b98b13253895ce9eac1f769183b274e4d5" + }, + "bun-webkit-linux-amd64-asan.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-amd64-asan.tar.gz", + "sha256": "47b897c98be6bef4bbc1d2d8f161690034cf96427ea80b8023bbc8e90944b725" + }, + "bun-webkit-linux-amd64-debug-asan.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-amd64-debug-asan.tar.gz", + "sha256": "a6f2d94276ca03950e10c197c432d5d9ead952ec6ea17e6c804a6424fe26196c" + }, + "bun-webkit-linux-amd64-debug.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-amd64-debug.tar.gz", + "sha256": "6be78f7676a2ffa54bbb4ed17e70af6bc9c1e83bbefc5f154fb9eb36bc321b46" + }, + "bun-webkit-linux-amd64-lto.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-amd64-lto.tar.gz", + "sha256": "4f05e212538c76654b07fc264c69adf4307baf0e35a6a30479c30b4e4d6c2d34" + }, + "bun-webkit-linux-amd64-musl-debug.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-amd64-musl-debug.tar.gz", + "sha256": "b2a6b0b485e3049d3e707b3b5e3ddd537e46006b48f16e0c43bfbf988d8a8b73" + }, + "bun-webkit-linux-amd64-musl-lto.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-amd64-musl-lto.tar.gz", + "sha256": "a915cf6892912e1287902501a4ef7e4d31af9f997fe5c87bd34268540ee7c9d2" + }, + "bun-webkit-linux-amd64-musl.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-amd64-musl.tar.gz", + "sha256": "aff06ccb5e97123f3e0fd5ba1e8d0a08ac5c7cf6ce6c0caff9fef23ca520d5cb" + }, + "bun-webkit-linux-amd64.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-amd64.tar.gz", + "sha256": "afd61497dca231d73d712b6496bf700d331ffb7aa2062ea606127ce6c9d81be0" + }, + "bun-webkit-linux-arm64-android-debug.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-arm64-android-debug.tar.gz", + "sha256": "318e6f1451627d41299ba9e4e5ae3fe3263c65e2749fb6b21e0061fc156d19c6" + }, + "bun-webkit-linux-arm64-android-lto.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-arm64-android-lto.tar.gz", + "sha256": "8feed910f4a7f9b017a6e8bd8ff9e6ab813a95373d66a4d8129c7bd91c64ef57" + }, + "bun-webkit-linux-arm64-android.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-arm64-android.tar.gz", + "sha256": "3eb8705074910082b24dc9349a05f78ed89ee8dc0e65fc5a8f01f57435acee48" + }, + "bun-webkit-linux-arm64-asan.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-arm64-asan.tar.gz", + "sha256": "0e4c6fcca7c786af232acac19339700587b2641603a3d36f9d9351bd9a7d3121" + }, + "bun-webkit-linux-arm64-debug-asan.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-arm64-debug-asan.tar.gz", + "sha256": "b4175f9b0bbc1bc4f897afdf6eef30c1691a580df44977d566a0818a21bd3267" + }, + "bun-webkit-linux-arm64-debug.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-arm64-debug.tar.gz", + "sha256": "d93182aa87e9c70bb4c4a6982e502b2281c48b0692ca83bd8b291c6dbfd60004" + }, + "bun-webkit-linux-arm64-lto.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-arm64-lto.tar.gz", + "sha256": "f35d9c4ded56b9cbc29f3b871541850bf6dd76e19160bfe972f7a86e448c4360" + }, + "bun-webkit-linux-arm64-musl-debug.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-arm64-musl-debug.tar.gz", + "sha256": "320fc966ed8e9da0adc70dcfe2b932e31530577366afd975dcd75faea4a3f3a9" + }, + "bun-webkit-linux-arm64-musl-lto.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-arm64-musl-lto.tar.gz", + "sha256": "f19ce5d2242b20fd02ff7ec383b56cca7d05a5d796acaad8fb5d54635cf09975" + }, + "bun-webkit-linux-arm64-musl.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-arm64-musl.tar.gz", + "sha256": "5a6d5ed9f6bdb7ed875a641d4d5adab57f66e8cab57dcf80da37f0b077bef4c9" + }, + "bun-webkit-linux-arm64.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-linux-arm64.tar.gz", + "sha256": "f2333bd2e534ce5ac0f1ec29cddfc1262e9b3921143ba4ef2f58a48d9e09e925" + }, + "bun-webkit-macos-amd64-debug.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-macos-amd64-debug.tar.gz", + "sha256": "c59b0dd68a9c9ce83641f9cde4411ad202c49c546791dafd0b006db710b7e9f6" + }, + "bun-webkit-macos-amd64-lto.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-macos-amd64-lto.tar.gz", + "sha256": "519539459157aaa83b6e065f7a222302b817fef5ab1f95c57b6897fa48b27c78" + }, + "bun-webkit-macos-amd64.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-macos-amd64.tar.gz", + "sha256": "8eb444f0f889bba51f94393c4ff6907aa2b3319633ec00ea67c0bc6a56588bb3" + }, + "bun-webkit-macos-arm64-asan.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-macos-arm64-asan.tar.gz", + "sha256": "e7c4197796158f9c9527d9e0e00f5a3c3585de847e6320745a16ebc44ed7b143" + }, + "bun-webkit-macos-arm64-debug-asan.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-macos-arm64-debug-asan.tar.gz", + "sha256": "062ed9c1608a780f16cb71b20b3a731d821240a24ca638a4f15d8ea05b7a3205" + }, + "bun-webkit-macos-arm64-debug.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-macos-arm64-debug.tar.gz", + "sha256": "8752c913fe69e460a642884f0c914bc419727ede0a4f6bd7cb2d675df3c782a0" + }, + "bun-webkit-macos-arm64-lto.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-macos-arm64-lto.tar.gz", + "sha256": "cbf77b8aa26748696a10fa093087fa3c22a8d172eddef63edd4cc00cf27cf14a" + }, + "bun-webkit-macos-arm64.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-macos-arm64.tar.gz", + "sha256": "12f17b6f8cb8b503edc2791fd22ac2a4273a3dfaba67c9931e6cdd3e194babf2" + }, + "bun-webkit-windows-amd64-asan.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-windows-amd64-asan.tar.gz", + "sha256": "6f7e2880591505c50e0e34a314874afc72b7213314feceed68a18259abd00866" + }, + "bun-webkit-windows-amd64-debug.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-windows-amd64-debug.tar.gz", + "sha256": "d67b3b155836e371ed4a640d20f6f77cf92776d073133c40c29ad731246ab366" + }, + "bun-webkit-windows-amd64-lto.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-windows-amd64-lto.tar.gz", + "sha256": "031cd72ffd47021861a75ed9e8f0220ffe6167cfcf5da568f978ec8ffca0ab38" + }, + "bun-webkit-windows-amd64.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-windows-amd64.tar.gz", + "sha256": "e2d7c7a70cba21e51e6f360676d363142f8fa490467889c2fd496dd3a136fafc" + }, + "bun-webkit-windows-arm64-debug.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-windows-arm64-debug.tar.gz", + "sha256": "bd3593e18db6b7882504a990e55db44605aee95370656bf439e755b55010ad6a" + }, + "bun-webkit-windows-arm64.tar.gz": { + "url": "https://github.com/oven-sh/WebKit/releases/download/autobuild-1600131e46b5af48bbda3559af8d8a3327230b6e/bun-webkit-windows-arm64.tar.gz", + "sha256": "d19b9ba8fcf2c5d5aff802cc520d81b088dbfac96214af83437496560b8506db" + } + } +} diff --git a/.github/openclaw/qualification/worker-resource-limits.test.ts b/.github/openclaw/qualification/worker-resource-limits.test.ts new file mode 100644 index 0000000000000..fa0d9a4b5e3b5 --- /dev/null +++ b/.github/openclaw/qualification/worker-resource-limits.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; +import { Worker } from "node:worker_threads"; + +describe("resourceLimits", () => { + test("reads numeric getters in Node order and converts the second value", async () => { + const calls: string[] = []; + const values = { maxOldGenerationSizeMb: 64, maxYoungGenerationSizeMb: 16, codeRangeSizeMb: 32, stackSizeMb: 2 }; + const limits = {}; + for (const key of Object.keys(values)) { + let count = 0; + Object.defineProperty(limits, key, { get() { calls.push(key); return ++count === 1 ? values[key] : String(values[key]); } }); + } + const worker = new Worker("", { eval: true, resourceLimits: limits }); + try { + expect(calls).toEqual(Object.keys(values).flatMap(key => [key, key])); + expect(worker.resourceLimits).toEqual(values); + } finally { + await worker.terminate(); + } + }); + + test("propagates a second getter exception unchanged", async () => { + const error = new Error("second resource-limit getter"); + let reads = 0; + let worker: Worker | undefined; + let caught: unknown; + try { + worker = new Worker("", { eval: true, resourceLimits: { get maxOldGenerationSizeMb() { if (++reads === 2) throw error; return 64; } } }); + } catch (value) { + caught = value; + } finally { + await worker?.terminate(); + } + expect(caught).toBe(error); + expect(reads).toBe(2); + }); + + async function runLimitedWorker(limits: object, body: string) { + await using child = Bun.spawn({ + cmd: [bunExe(), "-e", ` + const {Worker,resourceLimits}=require('node:worker_threads'); + const w=new Worker(${JSON.stringify("const {parentPort,resourceLimits}=require('node:worker_threads');parentPort.postMessage({limits:resourceLimits});parentPort.once('message',()=>{" + body + "});")},{eval:true,resourceLimits:${JSON.stringify(limits)}}); + const result={main:resourceLimits,before:w.resourceLimits,events:[],messages:[]}; + w.on('online',()=>{result.online=w.resourceLimits;}); + w.on('message',m=>{result.messages.push(m);if(m.limits)w.postMessage('go');}); + 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, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([child.stdout.text(), child.stderr.text(), child.exited]); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + return JSON.parse(stdout); + } + + test("reports requested values in the parent and worker, then clears on exit", async () => { + const limits = { maxYoungGenerationSizeMb: 16, maxOldGenerationSizeMb: 64, codeRangeSizeMb: 32, stackSizeMb: 2 }; + expect(await runLimitedWorker(limits, "")).toEqual({ + main: {}, before: limits, online: limits, messages: [{ limits }], events: ["exit"], exit: 0, after: {}, + }); + }); + + test.each([{}, { maxOldGenerationSizeMb: "32", maxYoungGenerationSizeMb: true, stackSizeMb: null }])("resolves defaults by online and ignores non-number fields: %j", async requested => { + const result = await runLimitedWorker(requested, ""); + const limits = { maxYoungGenerationSizeMb: 192, maxOldGenerationSizeMb: 4096, codeRangeSizeMb: 0, stackSizeMb: 4 }; + expect({ online: result.online, inside: result.messages[0].limits, after: result.after, exit: result.exit }).toEqual({ online: limits, inside: limits, after: {}, exit: 0 }); + }); + + 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');"); + expect({ events: result.events, error: result.error, exit: result.exit, after: result.after, messages: result.messages.length }).toEqual({ + events: ["error", "exit"], + error: { name: "Error", code: "ERR_WORKER_OUT_OF_MEMORY", message: "Worker terminated due to reaching memory limit: JS heap out of memory", limits: {} }, + exit: 1, after: {}, messages: 1, + }); + expect((await runLimitedWorker({ maxOldGenerationSizeMb: 64 }, "parentPort.postMessage('alive');")).messages.at(-1)).toBe("alive"); + }); + + test.each(["large", "small", "materialized", "shared"])("excludes %s external backing stores", async kind => { + const allocation = kind === "small" + ? "for(let i=0;i<65536;i++)held.push(new Uint8Array(2048).fill(7));" + : kind === "materialized" + ? "for(let i=0;i<32;i++)held.push(new Uint8Array(new ArrayBuffer(4*1024*1024)).fill(7));" + : kind === "shared" + ? "for(let i=0;i<32;i++)held.push(new Uint8Array(new SharedArrayBuffer(4*1024*1024)).fill(7));" + : "for(let i=0;i<32;i++)held.push(new Uint8Array(4*1024*1024).fill(7));"; + const result = await runLimitedWorker({ maxOldGenerationSizeMb: 32, maxYoungGenerationSizeMb: 4 }, + "globalThis.held=[];" + allocation + "Bun.gc(true);parentPort.postMessage(held.reduce((n,a)=>n+a.byteLength,0));"); + expect({ events: result.events, exit: result.exit, bytes: result.messages.at(-1) }).toEqual({ events: ["exit"], exit: 0, bytes: 128 * 1024 * 1024 }); + }); + + test("external allocations do not hide managed heap growth", async () => { + const result = await runLimitedWorker({ maxOldGenerationSizeMb: 32, maxYoungGenerationSizeMb: 4 }, + "globalThis.held=[new Uint8Array(128*1024*1024).fill(7)];Bun.gc(true);for(let i=0;i<64;i++)held.push(new Array(1024*1024).fill(i));"); + expect({ code: result.error?.code, exit: result.exit }).toEqual({ code: "ERR_WORKER_OUT_OF_MEMORY", exit: 1 }); + }); + + 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');"); + expect({ events: result.events, exit: result.exit, value: result.messages.at(-1) }).toEqual({ events: ["exit"], exit: 0, value: "retained" }); + }); + + test("stackSizeMb changes the worker stack capacity", async () => { + const body = "let depth=0;function recur(){depth++;return recur()+1;}try{recur();}catch(e){parentPort.postMessage({depth,name:e.name});}"; + const small = await runLimitedWorker({ stackSizeMb: 1 }, body); + const large = await runLimitedWorker({ stackSizeMb: 4 }, body); + expect({ small: small.exit, large: large.exit, smallError: small.messages.at(-1).name, largeError: large.messages.at(-1).name }).toEqual({ small: 0, large: 0, smallError: "RangeError", largeError: "RangeError" }); + expect(large.messages.at(-1).depth).toBeGreaterThan(small.messages.at(-1).depth * 2); + }); + + test("a vm timeout remains usable before a worker heap OOM", async () => { + const result = await runLimitedWorker({ maxOldGenerationSizeMb: 32, maxYoungGenerationSizeMb: 4 }, + "try{require('node:vm').runInNewContext('while(true){}',{},{timeout:10});}catch(e){parentPort.postMessage(e.code);}globalThis.held=[];for(let i=0;i<64;i++)held.push(new Array(1024*1024).fill(i));"); + expect({ timeout: result.messages[1], code: result.error?.code, exit: result.exit }).toEqual({ timeout: "ERR_SCRIPT_EXECUTION_TIMEOUT", code: "ERR_WORKER_OUT_OF_MEMORY", exit: 1 }); + }); +}); diff --git a/.github/openclaw/qualify.sh b/.github/openclaw/qualify.sh new file mode 100644 index 0000000000000..d4a60484c9d07 --- /dev/null +++ b/.github/openclaw/qualify.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail +umask 022 +pipeline=$(pwd) +inputs="$pipeline/.github/openclaw/qualification" +: "${ARTIFACT_DIR:?}" +: "${QUALIFICATION_DIR:?}" +mkdir -p "$QUALIFICATION_DIR" +QUALIFICATION_DIR=$(realpath "$QUALIFICATION_DIR") +BUN_COMMIT=$(python3 -c 'import json;print(json.load(open(".github/openclaw/variants.json"))["bun_commit"])') +BASE=1600131e46b5af48bbda3559af8d8a3327230b6e +SOURCE_SHA=$(git rev-parse HEAD) +export CI=1 PUPPETEER_SKIP_DOWNLOAD=1 +# Separate source/build/cache directories prevent an old engine ABI from being reused. +git clone --depth=1 --filter=blob:none --no-checkout https://github.com/openclaw/bun.git "$QUALIFICATION_DIR/bun" +cd "$QUALIFICATION_DIR/bun" +git fetch --depth=1 origin "$BUN_COMMIT" +git checkout --detach "$BUN_COMMIT" +git apply "$inputs/patches/000-checksums.patch" +bootstrap=$(command -v bun) +"$bootstrap" install --frozen-lockfile +(cd test && "$bootstrap" install --frozen-lockfile) +python3 "$inputs/stage-local-artifact.py" "$ARTIFACT_DIR/bun-webkit-linux-amd64.tar.gz" --version "$SOURCE_SHA" --output "$QUALIFICATION_DIR/candidate-input" +for arm in baseline candidate; do + if test "$arm" = candidate; then + for patch in "$inputs/patches/001-proxy.patch" "$inputs"/patches/00[3-7]-*.patch; do + git apply --exclude=CHANGELOG.md --exclude='docs/*' --exclude='test/js/node/worker_threads/*' "$patch" + done + 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" + export BUN_WEBKIT_ARTIFACT_MANIFEST="$QUALIFICATION_DIR/candidate-input/artifacts.json" + export BUN_BUILD_PREFETCH_DIR="$QUALIFICATION_DIR/candidate-input/prefetch" + revision="$SOURCE_SHA" + else + export BUN_WEBKIT_ARTIFACT_MANIFEST="$inputs/upstream-artifacts.json" + export BUN_BUILD_PREFETCH_DIR="$QUALIFICATION_DIR/baseline-prefetch" + revision="$BASE" + fi + export BUN_BUILD_CACHE_DIR="$QUALIFICATION_DIR/cache-$arm" + "$bootstrap" run build:release --lto=off --webkit-version="$revision" --buildDir="$QUALIFICATION_DIR/bun/build/qualify-$arm" --timings > "$QUALIFICATION_DIR/build-$arm.log" 2>&1 + candidate="$QUALIFICATION_DIR/bun/build/qualify-$arm/bun" + "$candidate" -e 'if(process.versions.webkit!==process.argv[1]) throw new Error("wrong engine");' "$revision" + sha256sum "$candidate" > "$QUALIFICATION_DIR/bun-$arm.sha256" + results="$QUALIFICATION_DIR/$arm" + mkdir -p "$results" "$results/home" "$results/tmp" "$results/bin" + chmod 700 "$results/home" "$results/tmp" + ln -s "$candidate" "$results/bin/bun" + mkdir -p build/openclaw-ci + cp "$inputs/selected.json" build/openclaw-ci/selected.json + cp "$inputs/selected.json" "$results/selected.json" + rm -f build/openclaw-ci/results.json + env -u BUN_WEBKIT_ARTIFACT_MANIFEST -u BUN_BUILD_CACHE_DIR -u BUN_BUILD_PREFETCH_DIR \ + HOME="$results/home" TMPDIR="$results/tmp" PATH="$results/bin:$PATH" OPENCLAW_CI_PLATFORM=linux \ + "$candidate" scripts/openclaw-ci/tests.ts test > "$results/tests.log" 2>&1 + cp build/openclaw-ci/results.json "$results/results.json" +done +python3 "$inputs/compare-fork-ci.py" "$QUALIFICATION_DIR/baseline" "$QUALIFICATION_DIR/candidate" --output "$QUALIFICATION_DIR/faithfulness.json" +candidate="$QUALIFICATION_DIR/bun/build/qualify-candidate/bun" +unset BUN_WEBKIT_ARTIFACT_MANIFEST BUN_BUILD_CACHE_DIR BUN_BUILD_PREFETCH_DIR +"$candidate" "$inputs/segmenter.js" > "$QUALIFICATION_DIR/segmenter.log" 2>&1 +"$candidate" test test/js/node/vm/vm.test.ts -t 'a Proxy in the prototype chain' > "$QUALIFICATION_DIR/proxy.log" 2>&1 +"$candidate" test test/js/node/worker_threads/openclaw-resource-limits.test.ts -t 'resourceLimits' > "$QUALIFICATION_DIR/resource-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' +import json,re,sys,hashlib +from pathlib import Path +root=Path(sys.argv[1]) +faith=json.loads((root/'faithfulness.json').read_text()) +assert faith['identical_file_outcomes'] and faith['all_passed'] +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)]: + 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, + 'bun_adapter_sha256':hashlib.sha256((root/'bun-adapters.patch').read_bytes()).hexdigest()},indent=2)+'\n') +PY diff --git a/.github/openclaw/release.py b/.github/openclaw/release.py new file mode 100644 index 0000000000000..c28a2151715e9 --- /dev/null +++ b/.github/openclaw/release.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Assemble checked lane outputs, then create one immutable release without replacement.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import time +import subprocess + +ROOT=Path(__file__).resolve().parents[2] +REPO='openclaw/WebKit' + +def sha(path): + with path.open('rb') as f:return hashlib.file_digest(f,'sha256').hexdigest() +def api(*args):return json.loads(subprocess.check_output(['gh','api',*args],text=True) or '{}') +def execute(*args):subprocess.run(args,check=True) +def save(p,x):p.write_text(json.dumps(x,indent=2)+'\n') + +def assemble(inputs,out,source): + out.mkdir(parents=True,exist_ok=False) + config=json.loads((ROOT/'.github/openclaw/variants.json').read_text()) + tag='autobuild-'+source + gate_path=inputs/'qualification/gate.json' + gate=json.loads(gate_path.read_text()) + if gate.get('passed') is not True or gate['source']!=source or gate['bun_commit']!=config['bun_commit']: + raise ValueError('missing or mismatched qualification gate') + artifacts={}; lanes=[] + for lane in config['lanes']: + label=lane['label']; name=label+'.tar.gz'; directory=inputs/label + manifest=json.loads((directory/(label+'.manifest.json')).read_text()) + if manifest['source']!=source or manifest['lane']['label']!=label: + raise ValueError('mixed source or lane') + f=directory/name + if sha(f)!=manifest['files'][name]['sha256'] or f.stat().st_size!=manifest['files'][name]['size']: + raise ValueError('lane digest mismatch') + shutil.copyfile(f,out/name) + artifacts[name]={'url':f'https://github.com/{REPO}/releases/download/{tag}/{name}','sha256':sha(f),'size':f.stat().st_size} + lanes.append(manifest) + # Retain low-level provenance and test logs as a separate archive. + execute('tar','-czf',str(out/'provenance.tar.gz'),'-C',str(inputs),*[ + item for lane in config['lanes'] for item in [lane['label']+'/provenance',lane['label']+'/'+lane['label']+'.manifest.json']], 'qualification') + license_text='OpenClaw WebKit source and licenses\n\n'+f'Source: https://github.com/{REPO}/tree/{source}\nSource archive: https://github.com/{REPO}/archive/{source}.tar.gz\n\n' + for p in ['Source/JavaScriptCore/COPYING.LIB','Source/WTF/wtf/COPYING.LIB']: + file=ROOT/p + if file.is_file():license_text+='\n'+p+'\n'+file.read_text()+'\n' + license_text+='\nIndividual files retain their copyright and license notices. ICU source and license: https://github.com/unicode-org/icu/tree/release-78-3\nToolchains and SDKs are downloaded from their upstream distributors; no toolchain image or SDK is published.\n' + (out/'LICENSE-SOURCES.txt').write_text(license_text) + save(out/'manifest.json',{'schema_version':1,'version':source,'repository':REPO,'tag':tag, + 'artifacts':artifacts,'variants':config,'qualification':gate,'lanes':lanes, + 'supporting_files':{p.name:{'sha256':sha(p),'size':p.stat().st_size} for p in out.iterdir() if p.name not in artifacts}}) + (out/'SHA256SUMS').write_text(''.join(sha(p)+' '+p.name+'\n' for p in sorted(out.iterdir()) if p.is_file())) + +def verify(directory,source): + m=json.loads((directory/'manifest.json').read_text()) + if m['version']!=source or m['tag']!='autobuild-'+source:raise ValueError('wrong release source') + files={**m['artifacts'],**m['supporting_files']} + for name,entry in files.items(): + if Path(name).name!=name or sha(directory/name)!=entry['sha256'] or (directory/name).stat().st_size!=entry['size']: + raise ValueError('manifest digest mismatch') + sums={} + for line in (directory/'SHA256SUMS').read_text().splitlines(): + digest,name=line.split(' ',1) + if name in sums or Path(name).name!=name or not re.fullmatch('[0-9a-f]{64}',digest):raise ValueError('invalid checksum entry') + sums[name]=digest + if sha(directory/name)!=digest:raise ValueError('SHA256SUMS mismatch') + if set(sums)!=set(files)|{'manifest.json'}:raise ValueError('incomplete checksum coverage') + if {p.name for p in directory.iterdir()}!=set(sums)|{'SHA256SUMS'}:raise ValueError('unexpected release files') + return m + +def authorize(source,run_id): + # This maintainer-side check uses the existing administrator login, never a new CI secret. + if not api('-H','Cache-Control: max-age=0',f'repos/{REPO}/immutable-releases')['enabled']: + raise ValueError('immutable releases must be enabled') + run=api('-H','Cache-Control: max-age=0',f'repos/{REPO}/actions/runs/{run_id}') + if (run['head_sha']!=source or run['head_branch']!='openclaw/main' + or run['event']!='workflow_dispatch' or run['path'].split('@',1)[0]!='.github/workflows/openclaw-artifacts.yml'): + raise ValueError('publication run does not match the reviewed source/workflow') + jobs=api('-H','Cache-Control: max-age=0',f'repos/{REPO}/actions/runs/{run_id}/jobs?filter=latest&per_page=100') + required=[j for j in jobs['jobs'] if j['name']!='publish'] + expected_builds={'build ('+l['label']+')' for l in json.loads((ROOT/'.github/openclaw/variants.json').read_text())['lanes']} + if ({j['name'] for j in required}!=expected_builds|{'preflight','test','assemble'} + or any(j['conclusion']!='success' or j['run_attempt']!=run['run_attempt'] for j in required)): + raise ValueError('all exact build, test and assembly jobs must pass') + pending=api('-H','Cache-Control: max-age=0',f'repos/{REPO}/actions/runs/{run_id}/pending_deployments') + deployment=next(d for d in pending if d['environment']['name']=='openclaw-artifacts') + if not deployment['current_user_can_approve']:raise ValueError('current identity cannot approve publication') + receipt=json.dumps({'source':source,'run_id':str(run_id),'run_attempt':str(run['run_attempt']), + 'verified_at':time.time(),'immutable_enabled':True},separators=(',',':')) + execute('gh','variable','set','OPENCLAW_IMMUTABILITY_RECEIPT','-R',REPO,'--env','openclaw-artifacts','--body',receipt) + api('-X','POST',f'repos/{REPO}/actions/runs/{run_id}/pending_deployments', + '-F','environment_ids[]='+str(deployment['environment']['id']),'-f','state=approved', + '-f','comment=Verified exact build/test jobs and immutable-release setting for this publication.') + +def validate_authorization(source): + # Environment variables become available after the protected job is approved. + receipt=json.loads(os.environ['IMMUTABILITY_RECEIPT']) + if (receipt.get('immutable_enabled') is not True or receipt.get('source')!=source + or receipt.get('run_id')!=os.environ['GITHUB_RUN_ID'] + or receipt.get('run_attempt')!=os.environ['GITHUB_RUN_ATTEMPT'] + or not -60 <= time.time()-receipt['verified_at'] <= 900): + raise ValueError('fresh maintainer immutability verification for this run is required') + +def publish(directory,source): + manifest=verify(directory,source);tag=manifest['tag'] + validate_authorization(source) + if os.environ.get('GITHUB_REF')!='refs/heads/openclaw/main' or os.environ.get('GITHUB_SHA')!=source: + raise ValueError('publishing requires reviewed openclaw/main source') + # Tag creation fails atomically if the name exists. Neither existing drafts nor releases are resumed. + api('-X','POST',f'repos/{REPO}/git/refs','-f','ref=refs/tags/'+tag,'-f','sha='+source) + body=f'OpenClaw-built WebKit at `{source}`. All selected lanes and Linux qualification passed.\n\nBatch 1: Segmenter surrogate boundaries; opt-in Proxy global prototypes; module-loader AsyncLocalStorage propagation; per-VM worker heap and stack budgets. Thanks @steipete and @robobun for the source changes and upstream work.\n\nSource and licenses: LICENSE-SOURCES.txt. Checksums: SHA256SUMS and manifest.json. Full build and test provenance: provenance.tar.gz.\n\nRollback by restoring the prior Bun manifest and WebKit pin together; prior releases remain available.\n' + draft=api('-X','POST',f'repos/{REPO}/releases','-f','tag_name='+tag,'-f','name='+tag,'-f','body='+body,'-F','draft=true') + release_id=draft['id'] + execute('gh','release','upload',tag,'-R',REPO,*[str(p) for p in sorted(directory.iterdir())]) + # Check uploaded draft asset digests before making it public. + draft=api(f'repos/{REPO}/releases/{release_id}') + expected={p.name:'sha256:'+sha(p) for p in directory.iterdir()} + if {a['name']:a.get('digest') for a in draft['assets']}!=expected:raise ValueError('uploaded asset digest mismatch') + execute('gh','release','edit',tag,'-R',REPO,'--draft=false') + released=api(f'repos/{REPO}/releases/tags/{tag}') + if released.get('immutable') is not True or released.get('draft'):raise ValueError('release did not become immutable') + # Independent network readback of every public asset, after publication. + downloaded=directory.parent/'downloaded-release' + downloaded.mkdir(exist_ok=False) + execute('gh','release','download',tag,'-R',REPO,'--dir',str(downloaded)) + verify(downloaded,source) + save(directory.parent/'published-release.json',{'tag':tag,'url':released['html_url'],'immutable':True,'assets':expected,'manifest_url':f'https://github.com/{REPO}/releases/download/{tag}/manifest.json'}) + +def main(): + p=argparse.ArgumentParser();p.add_argument('command',choices=['assemble','verify','authorize','publish']);p.add_argument('--source',required=True);p.add_argument('--directory',type=Path);p.add_argument('--inputs',type=Path);p.add_argument('--run-id',type=int) + a=p.parse_args() + if not re.fullmatch('[0-9a-f]{40}',a.source):raise ValueError('full source SHA required') + if a.command=='authorize':authorize(a.source,a.run_id) + elif a.command=='assemble':assemble(a.inputs.resolve(),a.directory.resolve(),a.source) + elif a.command=='verify':verify(a.directory.resolve(),a.source) + else:publish(a.directory.resolve(),a.source) + +if __name__ == "__main__": + main() diff --git a/.github/openclaw/test_release.py b/.github/openclaw/test_release.py new file mode 100644 index 0000000000000..d88b8e9155086 --- /dev/null +++ b/.github/openclaw/test_release.py @@ -0,0 +1,88 @@ +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import time +import unittest +from unittest.mock import patch + +spec=importlib.util.spec_from_file_location('release',Path(__file__).with_name('release.py')) +release=importlib.util.module_from_spec(spec);spec.loader.exec_module(release) +SOURCE='a'*40 + +class ReleaseIntegrity(unittest.TestCase): + def setUp(self): + self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup) + self.root=Path(self.temp.name);self.inputs=self.root/'inputs';self.inputs.mkdir() + self.out=self.root/'release' + config=json.loads((release.ROOT/'.github/openclaw/variants.json').read_text()) + q=self.inputs/'qualification';q.mkdir();(q/'gate.json').write_text(json.dumps({'passed':True,'source':SOURCE,'bun_commit':config['bun_commit']})) + for lane in config['lanes']: + label=lane['label'];d=self.inputs/label;d.mkdir();(d/'provenance').mkdir() + f=d/(label+'.tar.gz');f.write_bytes(label.encode()) + (d/(label+'.manifest.json')).write_text(json.dumps({'source':SOURCE,'lane':{'label':label},'files':{f.name:{'sha256':release.sha(f),'size':f.stat().st_size}}})) + def assemble(self):release.assemble(self.inputs,self.out,SOURCE) + def test_complete_set_roundtrip(self): + self.assemble();self.assertEqual(len(release.verify(self.out,SOURCE)['artifacts']),9) + def test_changed_lane_bytes_rejected(self): + next(self.inputs.glob('*/*.tar.gz')).write_bytes(b'tampered') + with self.assertRaises(ValueError):self.assemble() + def test_wrong_source_rejected(self): + p=next(self.inputs.glob('*/*.manifest.json'));m=json.loads(p.read_text());m['source']='b'*40;p.write_text(json.dumps(m)) + with self.assertRaises(ValueError):self.assemble() + def test_failed_gate_rejected(self): + p=self.inputs/'qualification/gate.json';m=json.loads(p.read_text());m['passed']=False;p.write_text(json.dumps(m)) + with self.assertRaises(ValueError):self.assemble() + def test_extra_asset_rejected(self): + self.assemble();(self.out/'unreviewed').write_text('x') + with self.assertRaises(ValueError):release.verify(self.out,SOURCE) + def test_manifest_tampering_rejected(self): + self.assemble();p=self.out/'manifest.json';p.write_text(p.read_text()+' ') + with self.assertRaises(ValueError):release.verify(self.out,SOURCE) + def test_existing_tag_stops_before_release_write(self): + self.assemble() + with patch.dict('os.environ',self.authorization()), patch.object(release,'api',side_effect=subprocess.CalledProcessError(1,['gh'])),patch.object(release,'execute') as execute: + with self.assertRaises(subprocess.CalledProcessError):release.publish(self.out,SOURCE) + execute.assert_not_called() + def test_mutable_repository_stops_before_tag(self): + self.assemble() + with patch.object(release,'api',return_value={'enabled':False}) as api,patch.object(release,'execute') as execute: + with self.assertRaises(ValueError):release.authorize(SOURCE,42) + self.assertEqual(api.call_count,1);execute.assert_not_called() + + def authorization(self,**changes): + receipt={'source':SOURCE,'run_id':'42','run_attempt':'1','verified_at':time.time(),'immutable_enabled':True,**changes} + return {'GITHUB_REF':'refs/heads/openclaw/main','GITHUB_SHA':SOURCE,'GITHUB_RUN_ID':'42','GITHUB_RUN_ATTEMPT':'1','IMMUTABILITY_RECEIPT':json.dumps(receipt)} + def test_receipt_is_bound_to_run_source_attempt_and_time(self): + for changes in [{'source':'b'*40},{'run_id':'43'},{'run_attempt':'2'},{'verified_at':time.time()-901},{'immutable_enabled':False}]: + with self.subTest(changes=changes),patch.dict('os.environ',self.authorization(**changes)): + with self.assertRaises(ValueError):release.validate_authorization(SOURCE) + def test_draft_digest_lookup_uses_release_id(self): + self.assemble() + def api(*args): + if args[:2]==('-X','POST') and args[2].endswith('/git/refs'):return {} + if args[:2]==('-X','POST') and args[2].endswith('/releases'):return {'id':123} + if args==(f'repos/{release.REPO}/releases/123',):return {'assets':[]} + self.fail('unexpected API lookup: '+repr(args)) + with patch.dict('os.environ',self.authorization()),patch.object(release,'api',side_effect=api),patch.object(release,'execute'): + with self.assertRaisesRegex(ValueError,'uploaded asset digest mismatch'):release.publish(self.out,SOURCE) + + def test_authorization_requires_every_exact_green_job(self): + run={'head_sha':SOURCE,'head_branch':'openclaw/main','event':'workflow_dispatch','path':'.github/workflows/openclaw-artifacts.yml@openclaw/main','run_attempt':1} + with patch.object(release,'api',side_effect=[{'enabled':True},run,{'jobs':[{'name':'test','conclusion':'success','run_attempt':1}]}]),patch.object(release,'execute') as execute: + with self.assertRaisesRegex(ValueError,'all exact build'):release.authorize(SOURCE,42) + execute.assert_not_called() + + def test_authorization_accepts_ref_qualified_path(self): + run={'head_sha':SOURCE,'head_branch':'openclaw/main','event':'workflow_dispatch','path':'.github/workflows/openclaw-artifacts.yml@openclaw/main','run_attempt':1} + config=json.loads((release.ROOT/'.github/openclaw/variants.json').read_text()) + names=['preflight','test','assemble']+['build ('+l['label']+')' for l in config['lanes']] + jobs={'jobs':[{'name':n,'conclusion':'success','run_attempt':1} for n in names]} + pending=[{'environment':{'name':'openclaw-artifacts','id':99},'current_user_can_approve':True}] + with patch.object(release,'api',side_effect=[{'enabled':True},run,jobs,pending,{}]) as api,patch.object(release,'execute') as execute: + release.authorize(SOURCE,42) + self.assertEqual(execute.call_count,1) + self.assertIn('state=approved',api.call_args.args) + +if __name__=='__main__':unittest.main() diff --git a/.github/openclaw/variants.json b/.github/openclaw/variants.json new file mode 100644 index 0000000000000..5220f2da23951 --- /dev/null +++ b/.github/openclaw/variants.json @@ -0,0 +1,17 @@ +{ + "bun_repository": "openclaw/bun", + "bun_commit": "b83b544ef744b6ab0dc1feadb59652261d64291d", + "base": "1600131e46b5af48bbda3559af8d8a3327230b6e", + "lanes": [ + {"label":"bun-webkit-linux-amd64","consumer":"openclaw-build-test.yml linux-x64: build:release --lto=off"}, + {"label":"bun-webkit-macos-arm64","consumer":"openclaw-build-test.yml darwin-arm64: build:release --lto=off"}, + {"label":"bun-webkit-linux-amd64-lto","consumer":"openclaw-release.yml default linux-x64"}, + {"label":"bun-webkit-linux-arm64-lto","consumer":"openclaw-release.yml default linux-arm64"}, + {"label":"bun-webkit-macos-arm64-lto","consumer":"openclaw-release.yml default darwin-arm64"}, + {"label":"bun-webkit-macos-amd64-lto","consumer":"openclaw-release.yml default darwin-x64"}, + {"label":"bun-webkit-linux-amd64-musl-lto","consumer":"openclaw-release.yml optional linux-x64-musl"}, + {"label":"bun-webkit-linux-arm64-musl-lto","consumer":"openclaw-release.yml optional linux-arm64-musl"}, + {"label":"bun-webkit-windows-amd64-lto","consumer":"openclaw-release.yml optional windows-x64"} + ], + "excluded": "Windows arm64, Android, FreeBSD, debug and ASAN are not selected by the fork's active OpenClaw workflows. There is no separate x64 baseline WebKit archive." +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 0102bdb6a741b..0000000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,549 +0,0 @@ -name: CI - -# Builds every lane of bun-webkit, publishes them as one GitHub release, and tests the lanes that are marked as tested. -# A push to main and a pull request run exactly the same jobs; they differ in the commit that is built (env.REF), in -# the name of the release (the `plan` job's `tag`), and in that a pull request gets a comment linking its release. -# -# plan โ”€โ”€> image โ”€โ”€> build, build-tested โ”€โ”€> release -# โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€> test -# โ”€โ”€> prune (main only) -# -# A lane is one