/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ /* vim: set ts=8 sts=2 et sw=2 tw=80: */ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ #include #include #include #include "gtest/gtest.h" #include "AvailableMemoryWatcher.h" #include "mozilla/Atomics.h" #include "mozilla/gtest/MozAssertions.h" #include "mozilla/Preferences.h" #include "mozilla/SpinEventLoopUntil.h" #include "mozilla/Unused.h" #include "mozilla/Vector.h" #include "nsComponentManagerUtils.h" #include "nsIObserver.h" #include "nsIObserverService.h" #include "nsServiceManagerUtils.h" #include "nsITimer.h" #include "nsMemoryPressure.h" #include "nsWindowsHelpers.h" #include "nsIWindowsRegKey.h" #include "nsXULAppAPI.h" #include "TelemetryFixture.h" #include "TelemetryTestHelpers.h" using namespace mozilla; namespace { static constexpr size_t kBytesInMB = 1024 * 1024; template bool WaitUntil(const ConditionT& aCondition, uint32_t aTimeoutMs) { const uint64_t t0 = ::GetTickCount64(); bool isTimeout = false; // The message queue can be empty and the loop stops // waiting for a new event before detecting timeout. // Creating a timer to fire a timeout event. nsCOMPtr timer; NS_NewTimerWithFuncCallback( getter_AddRefs(timer), [](nsITimer*, void* isTimeout) { *reinterpret_cast(isTimeout) = true; }, &isTimeout, aTimeoutMs, nsITimer::TYPE_ONE_SHOT, __func__); SpinEventLoopUntil("xpcom-tests:WaitUntil"_ns, [&]() -> bool { if (isTimeout) { return true; } bool done = aCondition(); if (done) { fprintf(stderr, "Done in %llu msec\n", ::GetTickCount64() - t0); } return done; }); return !isTimeout; } class Spinner final : public nsIObserver { nsCOMPtr mObserverSvc; nsDependentCString mTopicToWatch; Maybe mSubTopicToWatch; bool mTopicObserved; ~Spinner() = default; public: NS_DECL_ISUPPORTS Spinner(nsIObserverService* aObserverSvc, const char* const aTopic, const char16_t* const aSubTopic) : mObserverSvc(aObserverSvc), mTopicToWatch(aTopic), mSubTopicToWatch(aSubTopic ? Some(nsDependentString(aSubTopic)) : Nothing()), mTopicObserved(false) {} NS_IMETHOD Observe(nsISupports* aSubject, const char* aTopic, const char16_t* aData) override { if (mTopicToWatch == aTopic) { if ((mSubTopicToWatch.isNothing() && !aData) || mSubTopicToWatch.ref() == aData) { mTopicObserved = true; mObserverSvc->RemoveObserver(this, aTopic); // Force the loop to move in case that there is no event in the queue. nsCOMPtr dummyEvent = new Runnable(__func__); NS_DispatchToMainThread(dummyEvent); } } else { fprintf(stderr, "Unexpected topic: %s\n", aTopic); } return NS_OK; } void StartListening() { mTopicObserved = false; mObserverSvc->AddObserver(this, mTopicToWatch.get(), false); } bool Wait(uint32_t aTimeoutMs) { return WaitUntil([this]() { return this->mTopicObserved; }, aTimeoutMs); } }; NS_IMPL_ISUPPORTS(Spinner, nsIObserver) /** * Starts a new thread with a message queue to process * memory allocation/free requests */ class MemoryEater { using PageT = UniquePtr; static DWORD WINAPI ThreadStart(LPVOID aParam) { return reinterpret_cast(aParam)->ThreadProc(); } static void TouchMemory(void* aAddr, size_t aSize) { constexpr uint32_t kPageSize = 4096; volatile uint8_t x = 0; auto base = reinterpret_cast(aAddr); for (int64_t i = 0, pages = aSize / kPageSize; i < pages; ++i) { // Pick a random place in every allocated page // and dereference it. x ^= *(base + i * kPageSize + rand() % kPageSize); } (void)x; } static uint32_t GetAvailablePhysicalMemoryInMb() { MEMORYSTATUSEX statex = {sizeof(statex)}; if (!::GlobalMemoryStatusEx(&statex)) { return 0; } return static_cast(statex.ullAvailPhys / kBytesInMB); } static bool AddWorkingSet(size_t aSize, Vector& aOutput) { constexpr size_t kMinGranularity = 64 * 1024; size_t currentSize = aSize; while (aSize >= kMinGranularity) { if (!GetAvailablePhysicalMemoryInMb()) { // If the available physical memory is less than 1MB, we finish // allocation though there may be still the available commit space. fprintf(stderr, "No enough physical memory.\n"); return false; } PageT page(::VirtualAlloc(nullptr, currentSize, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)); if (!page) { DWORD gle = ::GetLastError(); if (gle != ERROR_COMMITMENT_LIMIT) { return false; } // Try again with a smaller allocation size. currentSize /= 2; continue; } aSize -= currentSize; // VirtualAlloc consumes the commit space, but we need to *touch* memory // to consume physical memory TouchMemory(page.get(), currentSize); Unused << aOutput.emplaceBack(std::move(page)); } return true; } DWORD mThreadId; nsAutoHandle mThread; nsAutoHandle mMessageQueueReady; Atomic mTaskStatus; enum class TaskType : UINT { Alloc = WM_USER, // WPARAM = Allocation size Free, Last, }; DWORD ThreadProc() { Vector stock; MSG msg; // Force the system to create a message queue ::PeekMessage(&msg, nullptr, WM_USER, WM_USER, PM_NOREMOVE); // Ready to get a message. Unblock the main thread. ::SetEvent(mMessageQueueReady.get()); for (;;) { BOOL result = ::GetMessage(&msg, reinterpret_cast(-1), WM_QUIT, static_cast(TaskType::Last)); if (result == -1) { return ::GetLastError(); } if (!result) { // Got WM_QUIT break; } switch (static_cast(msg.message)) { case TaskType::Alloc: mTaskStatus = AddWorkingSet(msg.wParam, stock); break; case TaskType::Free: stock = Vector(); mTaskStatus = true; break; default: MOZ_ASSERT_UNREACHABLE("Unexpected message in the queue"); break; } } return static_cast(msg.wParam); } bool PostTask(TaskType aTask, WPARAM aW = 0, LPARAM aL = 0) const { return !!::PostThreadMessageW(mThreadId, static_cast(aTask), aW, aL); } public: MemoryEater() : mThread(::CreateThread(nullptr, 0, ThreadStart, this, 0, &mThreadId)), mMessageQueueReady(::CreateEventW(nullptr, /*bManualReset*/ TRUE, /*bInitialState*/ FALSE, nullptr)) { ::WaitForSingleObject(mMessageQueueReady.get(), INFINITE); } ~MemoryEater() { ::PostThreadMessageW(mThreadId, WM_QUIT, 0, 0); if (::WaitForSingleObject(mThread.get(), 30000) != WAIT_OBJECT_0) { ::TerminateThread(mThread.get(), 0); } } bool GetTaskStatus() const { return mTaskStatus; } void RequestAlloc(size_t aSize) { PostTask(TaskType::Alloc, aSize); } void RequestFree() { PostTask(TaskType::Free); } }; class MockTabUnloader final : public nsITabUnloader { ~MockTabUnloader() = default; uint32_t mCounter; public: MockTabUnloader() : mCounter(0) {} NS_DECL_THREADSAFE_ISUPPORTS void ResetCounter() { mCounter = 0; } uint32_t GetCounter() const { return mCounter; } NS_IMETHOD UnloadTabAsync() override { ++mCounter; // Issue a memory-pressure to verify OnHighMemory issues // a memory-pressure-stop event. NS_NotifyOfEventualMemoryPressure(MemoryPressureState::LowMemory); return NS_OK; } }; NS_IMPL_ISUPPORTS(MockTabUnloader, nsITabUnloader) } // namespace class AvailableMemoryWatcherFixture : public TelemetryTestFixture { static const char kPrefLowCommitSpaceThreshold[]; RefPtr mWatcher; nsCOMPtr mObserverSvc; protected: static bool IsPageFileExpandable() { const auto kMemMgmtKey = u"SYSTEM\\CurrentControlSet\\Control\\" u"Session Manager\\Memory Management"_ns; nsresult rv; nsCOMPtr regKey = do_CreateInstance("@mozilla.org/windows-registry-key;1", &rv); if (NS_FAILED(rv)) { return false; } rv = regKey->Open(nsIWindowsRegKey::ROOT_KEY_LOCAL_MACHINE, kMemMgmtKey, nsIWindowsRegKey::ACCESS_READ); if (NS_FAILED(rv)) { return false; } nsAutoString pagingFiles; rv = regKey->ReadStringValue(u"PagingFiles"_ns, pagingFiles); if (NS_FAILED(rv)) { return false; } // The value data is REG_MULTI_SZ and each element is " ". // If the page file size is automatically managed for all drives, the // is set to "?:\pagefile.sys". // If the page file size is configured per drive, for a drive whose page // file is set to "system managed size", both and are set to 0. return !pagingFiles.IsEmpty() && (pagingFiles[0] == u'?' || FindInReadable(u" 0 0"_ns, pagingFiles)); } static size_t GetAllocationSizeToTriggerMemoryNotification() { // The percentage of the used physical memory to the total physical memory // size which is big enough to trigger a memory resource notification. constexpr uint32_t kThresholdPercentage = 98; // If the page file is not expandable, leave a little commit space. const uint32_t kMinimumSafeCommitSpaceMb = IsPageFileExpandable() ? 0 : 1024; MEMORYSTATUSEX statex = {sizeof(statex)}; EXPECT_TRUE(::GlobalMemoryStatusEx(&statex)); // How much memory needs to be used to trigger the notification const size_t targetUsedTotalMb = (statex.ullTotalPhys / kBytesInMB) * kThresholdPercentage / 100; // How much memory is currently consumed const size_t currentConsumedMb = (statex.ullTotalPhys - statex.ullAvailPhys) / kBytesInMB; if (currentConsumedMb >= targetUsedTotalMb) { fprintf(stderr, "The available physical memory is already low.\n"); return 0; } // How much memory we need to allocate to trigger the notification const uint32_t allocMb = targetUsedTotalMb - currentConsumedMb; // If we allocate the target amount, how much commit space will be // left available. const uint32_t estimtedAvailCommitSpace = std::max( 0, static_cast((statex.ullAvailPageFile / kBytesInMB) - allocMb)); // If the available commit space will be too low, we should not continue if (estimtedAvailCommitSpace < kMinimumSafeCommitSpaceMb) { fprintf(stderr, "The available commit space will be short - %d\n", estimtedAvailCommitSpace); return 0; } fprintf(stderr, "Total physical memory = %ul\n" "Available commit space = %ul\n" "Amount to allocate = %ul\n" "Future available commit space after allocation = %d\n", static_cast(statex.ullTotalPhys / kBytesInMB), static_cast(statex.ullAvailPageFile / kBytesInMB), allocMb, estimtedAvailCommitSpace); return allocMb * kBytesInMB; } static void SetThresholdAsPercentageOfCommitSpace(uint32_t aPercentage) { aPercentage = std::min(100u, aPercentage); MEMORYSTATUSEX statex = {sizeof(statex)}; EXPECT_TRUE(::GlobalMemoryStatusEx(&statex)); const uint32_t newVal = static_cast( (statex.ullAvailPageFile / kBytesInMB) * aPercentage / 100); fprintf(stderr, "Setting %s to %u\n", kPrefLowCommitSpaceThreshold, newVal); Preferences::SetUint(kPrefLowCommitSpaceThreshold, newVal); } static constexpr uint32_t kStateChangeTimeoutMs = 20000; static constexpr uint32_t kNotificationTimeoutMs = 20000; RefPtr mHighMemoryObserver; RefPtr mTabUnloader; MemoryEater mMemEater; nsAutoHandle mLowMemoryHandle; void SetUp() override { TelemetryTestFixture::SetUp(); mObserverSvc = do_GetService(NS_OBSERVERSERVICE_CONTRACTID); ASSERT_TRUE(mObserverSvc); mHighMemoryObserver = new Spinner(mObserverSvc, "memory-pressure-stop", nullptr); mTabUnloader = new MockTabUnloader; mWatcher = nsAvailableMemoryWatcherBase::GetSingleton(); mWatcher->RegisterTabUnloader(mTabUnloader); mLowMemoryHandle.own( ::CreateMemoryResourceNotification(LowMemoryResourceNotification)); ASSERT_TRUE(mLowMemoryHandle); // We set the threshold to 50% of the current available commit space. // This means we declare low-memory when the available commit space // gets lower than this threshold, otherwise we declare high-memory. SetThresholdAsPercentageOfCommitSpace(50); } void TearDown() override { StopUserInteraction(); Preferences::ClearUser(kPrefLowCommitSpaceThreshold); } bool WaitForMemoryResourceNotification() { uint64_t t0 = ::GetTickCount64(); if (::WaitForSingleObject(mLowMemoryHandle, kNotificationTimeoutMs) != WAIT_OBJECT_0) { fprintf(stderr, "The memory notification was not triggered.\n"); return false; } fprintf(stderr, "Notified in %llu msec\n", ::GetTickCount64() - t0); return true; } void StartUserInteraction() { mObserverSvc->NotifyObservers(nullptr, "user-interaction-active", nullptr); } void StopUserInteraction() { mObserverSvc->NotifyObservers(nullptr, "user-interaction-inactive", nullptr); } }; const char AvailableMemoryWatcherFixture::kPrefLowCommitSpaceThreshold[] = "browser.low_commit_space_threshold_mb"; class MemoryWatcherTelemetryEvent { static nsLiteralString sEventCategory; static nsLiteralString sEventMethod; static nsLiteralString sEventObject; uint32_t mLastCountOfEvents; public: explicit MemoryWatcherTelemetryEvent(JSContext* aCx) : mLastCountOfEvents(0) { JS::RootedValue snapshot(aCx); TelemetryTestHelpers::GetEventSnapshot(aCx, &snapshot); nsTArray eventValues = TelemetryTestHelpers::EventValuesToArray( aCx, snapshot, sEventCategory, sEventMethod, sEventObject); mLastCountOfEvents = eventValues.Length(); } void ValidateLastEvent(JSContext* aCx) { JS::RootedValue snapshot(aCx); TelemetryTestHelpers::GetEventSnapshot(aCx, &snapshot); nsTArray eventValues = TelemetryTestHelpers::EventValuesToArray( aCx, snapshot, sEventCategory, sEventMethod, sEventObject); // A new event was generated. EXPECT_EQ(eventValues.Length(), mLastCountOfEvents + 1); if (eventValues.IsEmpty()) { return; } // Update mLastCountOfEvents for a subsequent call to ValidateLastEvent ++mLastCountOfEvents; nsTArray tokens; for (const nsAString& token : eventValues.LastElement().Split(',')) { tokens.AppendElement(token); } EXPECT_EQ(tokens.Length(), 3U); if (tokens.Length() != 3U) { const wchar_t* valueStr = eventValues.LastElement().get(); fprintf(stderr, "Unexpected event value: %S\n", valueStr); return; } // Since this test does not involve TabUnloader, the first two numbers // are always expected to be zero. EXPECT_STREQ(tokens[0].get(), L"0"); EXPECT_STREQ(tokens[1].get(), L"0"); // The third token should be a valid floating number. nsresult rv; tokens[2].ToDouble(&rv); EXPECT_NS_SUCCEEDED(rv); } }; nsLiteralString MemoryWatcherTelemetryEvent::sEventCategory = u"memory_watcher"_ns; nsLiteralString MemoryWatcherTelemetryEvent::sEventMethod = u"on_high_memory"_ns; nsLiteralString MemoryWatcherTelemetryEvent::sEventObject = u"stats"_ns; TEST_F(AvailableMemoryWatcherFixture, AlwaysActive) { AutoJSContextWithGlobal cx(mCleanGlobal); MemoryWatcherTelemetryEvent telemetryEvent(cx.GetJSContext()); StartUserInteraction(); const size_t allocSize = GetAllocationSizeToTriggerMemoryNotification(); if (!allocSize) { // Not enough memory to safely create a low-memory situation. // Aborting the test without failure. return; } mTabUnloader->ResetCounter(); mMemEater.RequestAlloc(allocSize); if (!WaitForMemoryResourceNotification()) { // If the notification was not triggered, abort the test without failure // because it's not a fault in nsAvailableMemoryWatcher. return; } EXPECT_TRUE(WaitUntil([this]() { return mTabUnloader->GetCounter() >= 1; }, kStateChangeTimeoutMs)); mHighMemoryObserver->StartListening(); mMemEater.RequestFree(); EXPECT_TRUE(mHighMemoryObserver->Wait(kStateChangeTimeoutMs)); telemetryEvent.ValidateLastEvent(cx.GetJSContext()); } TEST_F(AvailableMemoryWatcherFixture, InactiveToActive) { AutoJSContextWithGlobal cx(mCleanGlobal); MemoryWatcherTelemetryEvent telemetryEvent(cx.GetJSContext()); const size_t allocSize = GetAllocationSizeToTriggerMemoryNotification(); if (!allocSize) { // Not enough memory to safely create a low-memory situation. // Aborting the test without failure. return; } mTabUnloader->ResetCounter(); mMemEater.RequestAlloc(allocSize); if (!WaitForMemoryResourceNotification()) { // If the notification was not triggered, abort the test without failure // because it's not a fault in nsAvailableMemoryWatcher. return; } mHighMemoryObserver->StartListening(); EXPECT_TRUE(WaitUntil([this]() { return mTabUnloader->GetCounter() >= 1; }, kStateChangeTimeoutMs)); mMemEater.RequestFree(); // OnHighMemory should not be triggered during no user interaction // eve after all memory was freed. Expecting false. EXPECT_FALSE(mHighMemoryObserver->Wait(3000)); StartUserInteraction(); // After user is active, we expect true. EXPECT_TRUE(mHighMemoryObserver->Wait(kStateChangeTimeoutMs)); telemetryEvent.ValidateLastEvent(cx.GetJSContext()); } TEST_F(AvailableMemoryWatcherFixture, HighCommitSpace_AlwaysActive) { // Setting a low threshold simulates a high commit space. SetThresholdAsPercentageOfCommitSpace(1); StartUserInteraction(); const size_t allocSize = GetAllocationSizeToTriggerMemoryNotification(); if (!allocSize) { // Not enough memory to safely create a low-memory situation. // Aborting the test without failure. return; } mTabUnloader->ResetCounter(); mMemEater.RequestAlloc(allocSize); if (!WaitForMemoryResourceNotification()) { // If the notification was not triggered, abort the test without failure // because it's not a fault in nsAvailableMemoryWatcher. return; } // Tab unload will not be triggered because the commit space is not low. EXPECT_FALSE(WaitUntil([this]() { return mTabUnloader->GetCounter() >= 1; }, kStateChangeTimeoutMs / 2)); mMemEater.RequestFree(); ::Sleep(kStateChangeTimeoutMs / 2); // Set a high threshold and make sure the watcher will trigger the tab // unloader next time. SetThresholdAsPercentageOfCommitSpace(50); mMemEater.RequestAlloc(allocSize); if (!WaitForMemoryResourceNotification()) { return; } EXPECT_TRUE(WaitUntil([this]() { return mTabUnloader->GetCounter() >= 1; }, kStateChangeTimeoutMs)); mHighMemoryObserver->StartListening(); mMemEater.RequestFree(); EXPECT_TRUE(mHighMemoryObserver->Wait(kStateChangeTimeoutMs)); } TEST_F(AvailableMemoryWatcherFixture, HighCommitSpace_InactiveToActive) { // Setting a low threshold simulates a high commit space. SetThresholdAsPercentageOfCommitSpace(1); const size_t allocSize = GetAllocationSizeToTriggerMemoryNotification(); if (!allocSize) { // Not enough memory to safely create a low-memory situation. // Aborting the test without failure. return; } mTabUnloader->ResetCounter(); mMemEater.RequestAlloc(allocSize); if (!WaitForMemoryResourceNotification()) { // If the notification was not triggered, abort the test without failure // because it's not a fault in nsAvailableMemoryWatcher. return; } // Tab unload will not be triggered because the commit space is not low. EXPECT_FALSE(WaitUntil([this]() { return mTabUnloader->GetCounter() >= 1; }, kStateChangeTimeoutMs / 2)); mMemEater.RequestFree(); ::Sleep(kStateChangeTimeoutMs / 2); // Set a high threshold and make sure the watcher will trigger the tab // unloader next time. SetThresholdAsPercentageOfCommitSpace(50); // When the user becomes active, the watcher will resume the timer. StartUserInteraction(); mMemEater.RequestAlloc(allocSize); if (!WaitForMemoryResourceNotification()) { return; } EXPECT_TRUE(WaitUntil([this]() { return mTabUnloader->GetCounter() >= 1; }, kStateChangeTimeoutMs)); mHighMemoryObserver->StartListening(); mMemEater.RequestFree(); EXPECT_TRUE(mHighMemoryObserver->Wait(kStateChangeTimeoutMs)); }