Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions Source/JavaScriptCore/API/tests/WindowsVMDecommitTest.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Copyright (C) 2025 Apple 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. AND ITS CONTRIBUTORS ``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 ITS 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.
*/

#include "config.h"
#include "WindowsVMDecommitTest.h"

#include <stdio.h>
#include <wtf/Assertions.h>

#if OS(WINDOWS)
#include <windows.h>
#include <psapi.h>
#include <wtf/OSAllocator.h>
#endif

WTF_ALLOW_UNSAFE_BUFFER_USAGE_BEGIN

#if OS(WINDOWS)
static SIZE_T workingSetSize()
{
PROCESS_MEMORY_COUNTERS counters;
counters.cb = sizeof(counters);
if (!GetProcessMemoryInfo(GetCurrentProcess(), &counters, sizeof(counters)))
return 0;
return counters.WorkingSetSize;
}
#endif

void testWindowsVMDecommit()
{
#if OS(WINDOWS)
printf("Testing Windows OSAllocator::hintMemoryNotNeededSoon releases pages (bun#30562)\n");

// 64 MiB is large enough to be unambiguous against background working-set
// noise (a few MiB of jitter between snapshots is normal) yet small enough
// to fit comfortably on any CI machine.
const size_t regionSize = 64 * 1024 * 1024;

// Reserve + commit a single region so we know the exact page range and so
// VirtualQuery in the fallback path walks over reservations we control.
void* region = VirtualAlloc(nullptr, regionSize, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
RELEASE_ASSERT(region);

// Touch every page to bring them into the working set. Without this,
// Windows lazy-commits — the pages won't show up in WorkingSetSize and
// there'd be nothing to observe being released.
SYSTEM_INFO systemInfo;
GetSystemInfo(&systemInfo);
const size_t pageSize = systemInfo.dwPageSize;
volatile unsigned char* bytes = static_cast<unsigned char*>(region);
for (size_t offset = 0; offset < regionSize; offset += pageSize)
bytes[offset] = static_cast<unsigned char>(offset & 0xff);

const SIZE_T before = workingSetSize();

// The call under test. Before the fix this was an empty stub — the
// working set would not change. After the fix it calls DiscardVirtualMemory
// (or the MEM_RESET + VirtualUnlock fallback) which evicts the pages.
WTF::OSAllocator::hintMemoryNotNeededSoon(region, regionSize);

const SIZE_T after = workingSetSize();

VirtualFree(region, 0, MEM_RELEASE);

printf(" working set before: %zu bytes\n", static_cast<size_t>(before));
printf(" working set after : %zu bytes\n", static_cast<size_t>(after));

// Allow for the OS to keep a small residue in the working set. We require
// at least half the region to have been evicted — the previous empty-stub
// implementation would produce ~0 bytes of drop.
const SIZE_T minDrop = regionSize / 2;
if (before < after || before - after < minDrop) {
fprintf(stderr, "FAIL: hintMemoryNotNeededSoon did not release pages — "
"working set drop was %zu bytes, expected at least %zu bytes.\n",
before > after ? static_cast<size_t>(before - after) : static_cast<size_t>(0),
static_cast<size_t>(minDrop));
RELEASE_ASSERT_NOT_REACHED();
}
Comment on lines +83 to +100
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Post-hint measurement is timing-sensitive and can flake

Line 83 takes only one immediate snapshot after the hint. For lazy reclaim paths, eviction may lag briefly, causing intermittent false failures.

Suggested fix
-    const SIZE_T after = workingSetSize();
+    const SIZE_T minDrop = regionSize / 2;
+    SIZE_T after = workingSetSize();
+    for (unsigned attempt = 0; attempt < 20 && (before < after || before - after < minDrop); ++attempt) {
+        Sleep(25);
+        SIZE_T sample = workingSetSize();
+        if (sample < after)
+            after = sample;
+    }
@@
-    const SIZE_T minDrop = regionSize / 2;
     if (before < after || before - after < minDrop) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Source/JavaScriptCore/API/tests/WindowsVMDecommitTest.cpp` around lines 83 -
100, The post-hint working set check is timing-sensitive: right now you take a
single snapshot via workingSetSize() (symbol workingSetSize) immediately after
VirtualFree(region, 0, MEM_RELEASE) and compare to minDrop (regionSize/2),
causing flakes on lazy reclaim paths; modify the test to poll/retry the after
snapshot a few times with small sleeps (exponential backoff or fixed short
sleeps) up to a timeout before failing, recomputing `after = workingSetSize()`
on each attempt and only evaluate the before/after/minDrop check after retries
expire, keeping the same failure message and RELEASE_ASSERT_NOT_REACHED()
behavior if the drop never meets minDrop.

printf("PASS: hintMemoryNotNeededSoon released %zu bytes from the working set\n",
static_cast<size_t>(before - after));
#else
printf("testWindowsVMDecommit: skipped (not Windows)\n");
#endif
}

WTF_ALLOW_UNSAFE_BUFFER_USAGE_END
39 changes: 39 additions & 0 deletions Source/JavaScriptCore/API/tests/WindowsVMDecommitTest.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright (C) 2025 Apple 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. AND ITS CONTRIBUTORS ``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 ITS 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.
*/

#pragma once

#ifdef __cplusplus
extern "C" {
#endif

/* Regression test for bun#30562: Windows vmDeallocatePhysicalPages and
* OSAllocator::hintMemoryNotNeededSoon must release physical pages, not
* just MEM_RESET. */
void testWindowsVMDecommit(void);

#ifdef __cplusplus
} /* extern "C" */
#endif
2 changes: 2 additions & 0 deletions Source/JavaScriptCore/API/tests/testapi.c
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
#include "PingPongStackOverflowTest.h"
#include "TypedArrayCTest.h"
#include "VMManagerStopTheWorldTest.h"
#include "WindowsVMDecommitTest.h"

WTF_ALLOW_UNSAFE_BUFFER_USAGE_BEGIN

Expand Down Expand Up @@ -1625,6 +1626,7 @@ int main(int argc, char* argv[])
return failed;

testCompareAndSwap();
testWindowsVMDecommit();
startMultithreadedMultiVMExecutionTest();

// Test garbage collection with a fresh context
Expand Down
1 change: 1 addition & 0 deletions Source/JavaScriptCore/shell/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ if (DEVELOPER_MODE)
../API/tests/PingPongStackOverflowTest.cpp
../API/tests/TypedArrayCTest.cpp
../API/tests/VMManagerStopTheWorldTest.cpp
../API/tests/WindowsVMDecommitTest.cpp
../API/tests/testapi.cpp
)
set(testapi_C_SOURCES
Expand Down
36 changes: 35 additions & 1 deletion Source/WTF/wtf/win/OSAllocatorWin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
#include "config.h"
#include <wtf/OSAllocator.h>

#include <algorithm>
#include <windows.h>
#include <wtf/Assertions.h>
#include <wtf/DataLog.h>
Expand Down Expand Up @@ -127,8 +128,41 @@ void OSAllocator::releaseDecommitted(void* address, size_t bytes, unsigned)
CRASH();
}

void OSAllocator::hintMemoryNotNeededSoon(void*, size_t)
void OSAllocator::hintMemoryNotNeededSoon(void* address, size_t bytes)
{
// Windows equivalent of madvise(MADV_DONTNEED). The pages must remain
// accessible (the POSIX version leaves the mapping in place and just
// drops the physical backing), so we can't MEM_DECOMMIT here.
//
// Previously this was an empty stub, which meant scavenger hints were
// silently dropped on Windows — long-running processes accumulated
// hundreds of MB of committed-but-zero pages that the OS never
// reclaimed (bun#30562).
//
// DiscardVirtualMemory releases the physical frames immediately and
// removes the pages from the working set while keeping the virtual
// mapping committed. On older Windows 10 builds and on ranges spanning
// multiple reservations it can fail, so we fall back to a
// per-reservation MEM_RESET loop + VirtualUnlock, which has the same
// effect with lazy reclaim. This mirrors the pattern already used in
// libpas (pas_page_malloc.c).
if (!bytes)
return;
if (DiscardVirtualMemory(address, bytes)) {
size_t totalSeen = 0;
void* currentPtr = address;
while (totalSeen < bytes) {
MEMORY_BASIC_INFORMATION memInfo;
SIZE_T queried = VirtualQuery(currentPtr, &memInfo, sizeof(memInfo));
if (!queried || !memInfo.RegionSize)
return;
size_t chunkSize = std::min<size_t>(memInfo.RegionSize, bytes - totalSeen);
VirtualAlloc(currentPtr, chunkSize, MEM_RESET, PAGE_READWRITE);
currentPtr = reinterpret_cast<void*>(reinterpret_cast<uintptr_t>(currentPtr) + memInfo.RegionSize);
totalSeen += memInfo.RegionSize;
}
VirtualUnlock(address, bytes);
}
}
Comment on lines +151 to 166
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fallback branch is inverted in hintMemoryNotNeededSoon

Line 151 currently runs the MEM_RESET/VirtualUnlock path only when DiscardVirtualMemory succeeds. If DiscardVirtualMemory fails, the function does nothing, so older/failing paths lose the intended fallback behavior.

Suggested fix
-    if (DiscardVirtualMemory(address, bytes)) {
+    if (!DiscardVirtualMemory(address, bytes)) {
         size_t totalSeen = 0;
         void* currentPtr = address;
         while (totalSeen < bytes) {
             MEMORY_BASIC_INFORMATION memInfo;
             SIZE_T queried = VirtualQuery(currentPtr, &memInfo, sizeof(memInfo));
             if (!queried || !memInfo.RegionSize)
                 return;
             size_t chunkSize = std::min<size_t>(memInfo.RegionSize, bytes - totalSeen);
             VirtualAlloc(currentPtr, chunkSize, MEM_RESET, PAGE_READWRITE);
             currentPtr = reinterpret_cast<void*>(reinterpret_cast<uintptr_t>(currentPtr) + memInfo.RegionSize);
             totalSeen += memInfo.RegionSize;
         }
         VirtualUnlock(address, bytes);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (DiscardVirtualMemory(address, bytes)) {
size_t totalSeen = 0;
void* currentPtr = address;
while (totalSeen < bytes) {
MEMORY_BASIC_INFORMATION memInfo;
SIZE_T queried = VirtualQuery(currentPtr, &memInfo, sizeof(memInfo));
if (!queried || !memInfo.RegionSize)
return;
size_t chunkSize = std::min<size_t>(memInfo.RegionSize, bytes - totalSeen);
VirtualAlloc(currentPtr, chunkSize, MEM_RESET, PAGE_READWRITE);
currentPtr = reinterpret_cast<void*>(reinterpret_cast<uintptr_t>(currentPtr) + memInfo.RegionSize);
totalSeen += memInfo.RegionSize;
}
VirtualUnlock(address, bytes);
}
}
if (!DiscardVirtualMemory(address, bytes)) {
size_t totalSeen = 0;
void* currentPtr = address;
while (totalSeen < bytes) {
MEMORY_BASIC_INFORMATION memInfo;
SIZE_T queried = VirtualQuery(currentPtr, &memInfo, sizeof(memInfo));
if (!queried || !memInfo.RegionSize)
return;
size_t chunkSize = std::min<size_t>(memInfo.RegionSize, bytes - totalSeen);
VirtualAlloc(currentPtr, chunkSize, MEM_RESET, PAGE_READWRITE);
currentPtr = reinterpret_cast<void*>(reinterpret_cast<uintptr_t>(currentPtr) + memInfo.RegionSize);
totalSeen += memInfo.RegionSize;
}
VirtualUnlock(address, bytes);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Source/WTF/wtf/win/OSAllocatorWin.cpp` around lines 151 - 166, The fallback
path inside hintMemoryNotNeededSoon is inverted: currently the
MEM_RESET/VirtualUnlock loop runs only when DiscardVirtualMemory succeeds;
change the condition so the MEM_RESET loop (using VirtualQuery, VirtualAlloc
with MEM_RESET, advancing by memInfo.RegionSize, and calling
VirtualUnlock(address, bytes) at the end) executes when
DiscardVirtualMemory(address, bytes) fails (i.e. if
(!DiscardVirtualMemory(...))). Keep the same loop logic and calls to
VirtualQuery/VirtualAlloc/VirtualUnlock but flip the if condition so the
fallback executes on failure of DiscardVirtualMemory.


bool OSAllocator::tryProtect(void* address, size_t bytes, bool readable, bool writable)
Expand Down
37 changes: 34 additions & 3 deletions Source/bmalloc/bmalloc/VMAllocate.h
Original file line number Diff line number Diff line change
Expand Up @@ -429,10 +429,41 @@ inline void vmZeroAndPurge(void* p, size_t vmSize, VMTag usage)

inline void vmDeallocatePhysicalPages(void* p, size_t vmSize)
{
// Windows equivalent of madvise(MADV_DONTNEED): release physical backing
// while keeping the virtual range committed and accessible. The paired
// vmAllocatePhysicalPages() re-commits on reuse.
//
// MEM_RESET alone is NOT sufficient — it marks contents as uninteresting
// but does not release physical frames or remove pages from the working
// set, so RSS and commit charge grow unboundedly on long-running
// processes (bun#30562).
//
// DiscardVirtualMemory frees the physical pages immediately and removes
// them from the working set. On older Windows 10 builds and on ranges
// spanning multiple reservations it can fail, so we fall back to a
// per-reservation MEM_RESET loop + VirtualUnlock, which evicts the pages
// from the working set with lazy reclaim. This mirrors the pattern
// already used in libpas (pas_page_malloc.c).
vmValidatePhysical(p, vmSize);
bool writable = true;
bool executable = true;
VirtualAlloc(p, vmSize, MEM_RESET, protection(writable, executable));
if (DiscardVirtualMemory(p, vmSize)) {
bool writable = true;
bool executable = true;
size_t totalSeen = 0;
void* currentPtr = p;
while (totalSeen < vmSize) {
MEMORY_BASIC_INFORMATION memInfo;
SIZE_T bytes = VirtualQuery(currentPtr, &memInfo, sizeof(memInfo));
RELEASE_BASSERT(bytes == sizeof(memInfo));
RELEASE_BASSERT(memInfo.RegionSize > 0);
size_t chunkSize = std::min<size_t>(memInfo.RegionSize, vmSize - totalSeen);
VirtualAlloc(currentPtr, chunkSize, MEM_RESET, protection(writable, executable));
currentPtr = reinterpret_cast<void*>(reinterpret_cast<uintptr_t>(currentPtr) + memInfo.RegionSize);
totalSeen += memInfo.RegionSize;
}
// VirtualUnlock of unlocked pages evicts them from the working set,
// which MEM_RESET alone does not do.
VirtualUnlock(p, vmSize);
Comment on lines +459 to +465
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're discarding the return value for both VirtualAlloc and for VirtualUnlock

}
Comment on lines +448 to +466
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

vmDeallocatePhysicalPages skips fallback on discard failure

Line 448 gates the MEM_RESET + VirtualUnlock path behind successful DiscardVirtualMemory. That leaves failure cases without any release hint, which defeats the intended compatibility fallback.

Suggested fix
-    if (DiscardVirtualMemory(p, vmSize)) {
+    if (!DiscardVirtualMemory(p, vmSize)) {
         bool writable = true;
         bool executable = true;
         size_t totalSeen = 0;
         void* currentPtr = p;
         while (totalSeen < vmSize) {
             MEMORY_BASIC_INFORMATION memInfo;
             SIZE_T bytes = VirtualQuery(currentPtr, &memInfo, sizeof(memInfo));
             RELEASE_BASSERT(bytes == sizeof(memInfo));
             RELEASE_BASSERT(memInfo.RegionSize > 0);
             size_t chunkSize = std::min<size_t>(memInfo.RegionSize, vmSize - totalSeen);
             VirtualAlloc(currentPtr, chunkSize, MEM_RESET, protection(writable, executable));
             currentPtr = reinterpret_cast<void*>(reinterpret_cast<uintptr_t>(currentPtr) + memInfo.RegionSize);
             totalSeen += memInfo.RegionSize;
         }
         // VirtualUnlock of unlocked pages evicts them from the working set,
         // which MEM_RESET alone does not do.
         VirtualUnlock(p, vmSize);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (DiscardVirtualMemory(p, vmSize)) {
bool writable = true;
bool executable = true;
size_t totalSeen = 0;
void* currentPtr = p;
while (totalSeen < vmSize) {
MEMORY_BASIC_INFORMATION memInfo;
SIZE_T bytes = VirtualQuery(currentPtr, &memInfo, sizeof(memInfo));
RELEASE_BASSERT(bytes == sizeof(memInfo));
RELEASE_BASSERT(memInfo.RegionSize > 0);
size_t chunkSize = std::min<size_t>(memInfo.RegionSize, vmSize - totalSeen);
VirtualAlloc(currentPtr, chunkSize, MEM_RESET, protection(writable, executable));
currentPtr = reinterpret_cast<void*>(reinterpret_cast<uintptr_t>(currentPtr) + memInfo.RegionSize);
totalSeen += memInfo.RegionSize;
}
// VirtualUnlock of unlocked pages evicts them from the working set,
// which MEM_RESET alone does not do.
VirtualUnlock(p, vmSize);
}
if (!DiscardVirtualMemory(p, vmSize)) {
bool writable = true;
bool executable = true;
size_t totalSeen = 0;
void* currentPtr = p;
while (totalSeen < vmSize) {
MEMORY_BASIC_INFORMATION memInfo;
SIZE_T bytes = VirtualQuery(currentPtr, &memInfo, sizeof(memInfo));
RELEASE_BASSERT(bytes == sizeof(memInfo));
RELEASE_BASSERT(memInfo.RegionSize > 0);
size_t chunkSize = std::min<size_t>(memInfo.RegionSize, vmSize - totalSeen);
VirtualAlloc(currentPtr, chunkSize, MEM_RESET, protection(writable, executable));
currentPtr = reinterpret_cast<void*>(reinterpret_cast<uintptr_t>(currentPtr) + memInfo.RegionSize);
totalSeen += memInfo.RegionSize;
}
// VirtualUnlock of unlocked pages evicts them from the working set,
// which MEM_RESET alone does not do.
VirtualUnlock(p, vmSize);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Source/bmalloc/bmalloc/VMAllocate.h` around lines 448 - 466, The code
currently only performs the MEM_RESET + VirtualUnlock release hint when
DiscardVirtualMemory(p, vmSize) returns true, leaving callers without a fallback
on DiscardVirtualMemory failure; change vmDeallocatePhysicalPages so that the
loop that walks regions (using VirtualQuery, VirtualAlloc with MEM_RESET, and
advancing currentPtr/totalSeen) and the final VirtualUnlock(p, vmSize) run as a
fallback when DiscardVirtualMemory fails (i.e., do not gate the
MEM_RESET/VirtualUnlock logic strictly on DiscardVirtualMemory success),
preserving the existing region iteration logic and assertions
(MEMORY_BASIC_INFORMATION, memInfo.RegionSize, totalSeen) and using the same
protection(writable, executable) call for VirtualAlloc.

}

inline void vmAllocatePhysicalPages(void* p, size_t vmSize)
Expand Down
Loading