/* -*- 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 <algorithm>
#include <windows.h>
#include <memoryapi.h>
#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 <typename ConditionT>
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<nsITimer> timer;
  NS_NewTimerWithFuncCallback(
      getter_AddRefs(timer),
      [](nsITimer*, void* isTimeout) {
        *reinterpret_cast<bool*>(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<nsIObserverService> mObserverSvc;
  nsDependentCString mTopicToWatch;
  Maybe<nsDependentString> 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<nsIRunnable> 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<void, VirtualFreeDeleter>;

  static DWORD WINAPI ThreadStart(LPVOID aParam) {
    return reinterpret_cast<MemoryEater*>(aParam)->ThreadProc();
  }

  static void TouchMemory(void* aAddr, size_t aSize) {
    constexpr uint32_t kPageSize = 4096;
    volatile uint8_t x = 0;
    auto base = reinterpret_cast<uint8_t*>(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<uint32_t>(statex.ullAvailPhys / kBytesInMB);
  }

  static bool AddWorkingSet(size_t aSize, Vector<PageT>& 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<bool> mTaskStatus;

  enum class TaskType : UINT {
    Alloc = WM_USER,  // WPARAM = Allocation size
    Free,

    Last,
  };

  DWORD ThreadProc() {
    Vector<PageT> 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<HWND>(-1), WM_QUIT,
                                 static_cast<UINT>(TaskType::Last));
      if (result == -1) {
        return ::GetLastError();
      }
      if (!result) {
        // Got WM_QUIT
        break;
      }

      switch (static_cast<TaskType>(msg.message)) {
        case TaskType::Alloc:
          mTaskStatus = AddWorkingSet(msg.wParam, stock);
          break;
        case TaskType::Free:
          stock = Vector<PageT>();
          mTaskStatus = true;
          break;
        default:
          MOZ_ASSERT_UNREACHABLE("Unexpected message in the queue");
          break;
      }
    }

    return static_cast<DWORD>(msg.wParam);
  }

  bool PostTask(TaskType aTask, WPARAM aW = 0, LPARAM aL = 0) const {
    return !!::PostThreadMessageW(mThreadId, static_cast<UINT>(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<nsAvailableMemoryWatcherBase> mWatcher;
  nsCOMPtr<nsIObserverService> mObserverSvc;

 protected:
  static bool IsPageFileExpandable() {
    const auto kMemMgmtKey =
        u"SYSTEM\\CurrentControlSet\\Control\\"
        u"Session Manager\\Memory Management"_ns;

    nsresult rv;
    nsCOMPtr<nsIWindowsRegKey> 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 "<path> <min> <max>".
    // If the page file size is automatically managed for all drives, the <path>
    // 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 <min> and <max> 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<int32_t>((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<uint32_t>(statex.ullTotalPhys / kBytesInMB),
            static_cast<uint32_t>(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<uint32_t>(
        (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<Spinner> mHighMemoryObserver;
  RefPtr<MockTabUnloader> 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<nsString> eventValues = TelemetryTestHelpers::EventValuesToArray(
        aCx, snapshot, sEventCategory, sEventMethod, sEventObject);
    mLastCountOfEvents = eventValues.Length();
  }

  void ValidateLastEvent(JSContext* aCx) {
    JS::RootedValue snapshot(aCx);
    TelemetryTestHelpers::GetEventSnapshot(aCx, &snapshot);
    nsTArray<nsString> 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<nsString> 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));
}
