blob: 0e8d8b2907ea7ca37aee4480fea7d202e80bae53 [file] [edit]
//
// Copyright 2026 The ANGLE Project Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
//
// test_blob_cache.cpp:
// A disk-backed EGL_ANDROID_blob_cache for tests, modelled on Android's MultifileBlobCache.
//
// Desktop platforms have no system blob cache. This one stores each entry as a file in a
// directory, so what a test stores survives a Display teardown, and it behaves as Android's does:
//
// - Entry files are in Android's format: named by a hash of the key, holding a header (magic,
// CRC, key size, value size), the key and the value. Each entry is charged its file size.
// - A key or value larger than the per-entry limit is refused. Like Android, it logs a warning,
// but the caller isn't told that its value went nowhere.
// - Storing an empty value for an existing entry removes it. For a key with no entry, an empty
// value is stored like any other.
// - Crossing the total size or entry limit evicts least recently used entries until the cache is
// down to half of both limits.
// - Opening a directory reads the entries already in it, as Android does at startup, in the order
// they were last used.
//
// Problems are logged the way Android logs them (frameworks/native/opengl/libs/EGL/
// MultifileBlobCache.cpp): warnings for refused stores, a full cache, hash collisions and entries
// changed on disk, and errors for I/O failures. EGL_ANDROID_blob_cache gives the callbacks no way
// to report failure, so the log is the only place they show up.
//
#include "util/test_blob_cache.h"
#include <algorithm>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <map>
#include <mutex>
#include <string>
#include <system_error>
#include <tuple>
#include <vector>
#include "common/debug.h"
#include "common/span.h"
#include "common/system_utils.h"
#include "common/unsafe_buffers.h"
#include "compression_utils_portable.h"
#if defined(ANGLE_PLATFORM_WINDOWS)
# include <process.h>
#else
# include <unistd.h>
#endif
namespace
{
// Android's entry file starts with this header (MultifileHeader in
// frameworks/native/opengl/libs/EGL/MultifileBlobCache.h), followed by the key and the value. The
// CRC covers the key and the value.
struct EntryHeader
{
uint32_t magic;
uint32_t crc;
EGLsizeiANDROID keySize;
EGLsizeiANDROID valueSize;
};
// 'MFB$' as Android writes it, which is "$BFM" on disk.
constexpr uint32_t kEntryMagic = 0x4D464224;
using ByteSpan = angle::Span<const uint8_t>;
// Crossing a total limit evicts down to the limit divided by this, not just enough to fit. See
// kCacheLimitDivisor in frameworks/native/opengl/libs/EGL/MultifileBlobCache.cpp.
constexpr size_t kCacheLimitDivisor = 2;
int GetCurrentProcessID()
{
#if defined(ANGLE_PLATFORM_WINDOWS)
return _getpid();
#else
return static_cast<int>(getpid());
#endif
}
struct Entry
{
size_t keySize = 0;
size_t valueSize = 0;
uint64_t lastAccess = 0;
};
// Every member is guarded by mutex. ANGLE calls the callbacks from any thread.
struct State
{
std::mutex mutex;
std::filesystem::path directory;
TestBlobCacheLimits limits;
TestBlobCacheStats stats;
// Keyed by the hash of the key, which names the entry's file. As on Android, keys with the
// same hash share an entry.
std::map<uint32_t, Entry> entries;
// Orders entries by last access for eviction. A counter rather than a clock, so that entries
// touched in quick succession never tie.
uint64_t accessClock = 0;
// The time recorded for the most recent use, so each use can be given a later one. See
// RecordUseLocked().
std::filesystem::file_time_type lastUseTime = {};
// Whether the callbacks have warned that no directory is set, so they only warn once.
bool warnedNoDirectory = false;
};
State &GetState()
{
static State *state = new State;
return *state;
}
// The size Android charges an entry: its whole file.
size_t EntryFileSize(size_t keySize, size_t valueSize)
{
return sizeof(EntryHeader) + keySize + valueSize;
}
// One mixing step of Bob Jenkins' one-at-a-time hash
// (https://en.wikipedia.org/wiki/Jenkins_hash_function), as android::JenkinsHashMix()
// (system/core/libutils/include/utils/JenkinsHash.h) implements it.
uint32_t JenkinsHashMix(uint32_t hash, uint32_t data)
{
hash += data;
hash += hash << 10;
hash ^= hash >> 6;
return hash;
}
// android::JenkinsHashMixBytes() (system/core/libutils/JenkinsHash.cpp) with a seed of zero, as
// MultifileBlobCache uses to name its entry files. It mixes in the length, then the key a word at
// a time, and leaves out the final avalanche step (android::JenkinsHashWhiten()), as Android does.
uint32_t HashKey(ByteSpan key)
{
// Mix in the length, then the key four bytes at a time, little endian
const size_t size = key.size();
uint32_t hash = JenkinsHashMix(0, static_cast<uint32_t>(size));
size_t i = 0;
for (; i + 4 <= size; i += 4)
{
hash = JenkinsHashMix(hash, key[i] | (key[i + 1] << 8) | (key[i + 2] << 16) |
(static_cast<uint32_t>(key[i + 3]) << 24));
}
// Then the last one to three bytes, if any, as one partial word
if (i < size)
{
uint32_t data = key[i];
if (i + 1 < size)
{
data |= key[i + 1] << 8;
}
if (i + 2 < size)
{
data |= key[i + 2] << 16;
}
hash = JenkinsHashMix(hash, data);
}
return hash;
}
// The entry CRC is zlib's crc32(), as Android computes it (GenerateCRC32() in
// frameworks/native/opengl/libs/EGL/FileBlobCache.cpp) and as ANGLE's own angle::GenerateCRC32()
// does. Continues the CRC |crc| over |bytes|, so that the key and the value can be covered without
// copying them together. A CRC starts from zero.
uint32_t CRC32(uint32_t crc, ByteSpan bytes)
{
return static_cast<uint32_t>(crc32_z(crc, bytes.data(), bytes.size()));
}
// Entry files are named with the decimal hash, as std::to_string() writes it. Anything else in
// the directory is not an entry.
bool ParseEntryName(const std::string &name, uint32_t *hashOut)
{
// At most ten decimal digits, the length of UINT32_MAX
if (name.empty() || name.size() > 10 ||
name.find_first_not_of("0123456789") != std::string::npos)
{
return false;
}
// Reject values that don't fit in 32 bits, and spellings std::to_string() wouldn't produce,
// such as leading zeros
const uint64_t hash = std::stoull(name);
if (hash > UINT32_MAX || std::to_string(hash) != name)
{
return false;
}
*hashOut = static_cast<uint32_t>(hash);
return true;
}
std::filesystem::path EntryPath(const State &state, uint32_t hash)
{
return state.directory / std::to_string(hash);
}
// Logs, once, that the callbacks are being ignored because no directory is set. ANGLE calls them
// on every compile, so warning on each call would flood the log.
void WarnNoDirectoryLocked(State &state)
{
if (!state.warnedNoDirectory)
{
WARN() << "TestBlobCache: No directory set, ignoring the cache until "
"InitTestBlobCache() is given one";
state.warnedNoDirectory = true;
}
}
// Records a use of an entry in its file's write time, which orders the entries when the cache is
// opened again. Android records uses in the access time, but std::filesystem only offers the write
// time. The clock can be coarse (on Windows it may only advance every few milliseconds), so
// entries used in quick succession would tie and lose their order. Each use is therefore recorded
// at least a microsecond after the previous one.
void RecordUseLocked(State &state, const std::filesystem::path &path)
{
const std::filesystem::file_time_type next =
state.lastUseTime + std::chrono::duration_cast<std::filesystem::file_time_type::duration>(
std::chrono::microseconds(1));
state.lastUseTime = std::max(std::filesystem::file_time_type::clock::now(), next);
std::error_code ec;
std::filesystem::last_write_time(path, state.lastUseTime, ec);
if (ec)
{
WARN() << "TestBlobCache: Failed to record the last use of " << path.string() << ": "
<< ec.message();
}
}
// Forgets an entry and deletes its file.
void RemoveEntryLocked(State &state, std::map<uint32_t, Entry>::iterator it)
{
state.stats.totalSize -= EntryFileSize(it->second.keySize, it->second.valueSize);
// A file that's already gone is fine; only a failure to delete one is worth a warning, as on
// Android
const std::filesystem::path path = EntryPath(state, it->first);
std::error_code ec;
std::filesystem::remove(path, ec);
if (ec)
{
WARN() << "TestBlobCache: Error removing " << path.string() << ": " << ec.message();
}
state.entries.erase(it);
}
// Evicts least recently used entries until the cache is within both limits.
void ApplyLRULocked(State &state, size_t sizeLimit, size_t entryLimit)
{
// Sort the entries oldest first
std::vector<std::pair<uint64_t, uint32_t>> byAge;
byAge.reserve(state.entries.size());
for (const auto &entry : state.entries)
{
byAge.emplace_back(entry.second.lastAccess, entry.first);
}
std::sort(byAge.begin(), byAge.end());
// Remove them in that order until both the size and the entry count fit
const uint64_t evictionsBefore = state.stats.evictions;
for (const auto &oldest : byAge)
{
if (state.stats.totalSize <= sizeLimit && state.entries.size() <= entryLimit)
{
break;
}
RemoveEntryLocked(state, state.entries.find(oldest.second));
++state.stats.evictions;
}
INFO() << "TestBlobCache: Evicted " << (state.stats.evictions - evictionsBefore)
<< " entries, leaving " << state.entries.size() << " entries and "
<< state.stats.totalSize << " bytes";
}
// The EGL_ANDROID_blob_cache set callback, following MultifileBlobCache::set(). A store replaces
// any existing entry for the key, and an empty value removes it.
void SetBlob(const void *key, EGLsizeiANDROID keySize, const void *value, EGLsizeiANDROID valueSize)
{
State &state = GetState();
// Guards the entries, the files and the statistics.
std::lock_guard<std::mutex> lock(state.mutex);
// Without a directory there is nowhere to store anything
if (state.directory.empty())
{
WarnNoDirectoryLocked(state);
return;
}
// Arguments ANGLE should never pass
if (key == nullptr || keySize <= 0 || valueSize < 0 || (valueSize > 0 && value == nullptr))
{
ERR() << "TestBlobCache: Invalid store, key " << key << " keySize " << keySize << " value "
<< value << " valueSize " << valueSize;
return;
}
// Refuse keys and values over the per-entry limits. Android logs a warning, but the caller
// isn't told its value went nowhere.
const size_t keyBytes = static_cast<size_t>(keySize);
const size_t size = static_cast<size_t>(valueSize);
if (keyBytes > state.limits.maxKeySize || size > state.limits.maxValueSize)
{
WARN() << "TestBlobCache: Refusing store, keySize (" << keyBytes << " vs "
<< state.limits.maxKeySize << ") or valueSize (" << size << " vs "
<< state.limits.maxValueSize << ") too large";
++state.stats.rejectedStores;
return;
}
// SAFETY: EGL_ANDROID_blob_cache passes |keySize| bytes at |key|, checked above.
const ByteSpan keySpan =
ANGLE_UNSAFE_BUFFERS(ByteSpan(static_cast<const uint8_t *>(key), keyBytes));
// SAFETY: EGL_ANDROID_blob_cache passes |valueSize| bytes at |value|, checked above. |value|
// may be null when |valueSize| is zero, which makes an empty span.
const ByteSpan valueSpan =
ANGLE_UNSAFE_BUFFERS(ByteSpan(static_cast<const uint8_t *>(value), size));
// A store replaces the whole entry, so the old one is released first. As in Android's
// MultifileBlobCache::set(), an empty value for an existing entry is a request to remove it, so
// there is nothing more to do. For a key with no entry, an empty value is stored like any
// other.
const uint32_t hash = HashKey(keySpan);
auto existing = state.entries.find(hash);
if (existing != state.entries.end())
{
RemoveEntryLocked(state, existing);
if (size == 0)
{
INFO() << "TestBlobCache: Removed the entry for hash " << hash
<< ", which was given an empty value";
return;
}
}
// If the new entry would cross either total limit, evict down to half of both limits first
const size_t fileSize = EntryFileSize(keyBytes, size);
if (state.stats.totalSize + fileSize > state.limits.maxTotalSize ||
state.entries.size() + 1 > state.limits.maxTotalEntries)
{
WARN() << "TestBlobCache: Cache is full (" << state.entries.size() << " entries, "
<< state.stats.totalSize << " bytes), evicting least recently used entries";
ApplyLRULocked(state, state.limits.maxTotalSize / kCacheLimitDivisor,
state.limits.maxTotalEntries / kCacheLimitDivisor);
}
// Build the file in Android's format: the header, then the key, then the value, with the CRC
// covering the key and the value
const EntryHeader header = {kEntryMagic, CRC32(CRC32(0, keySpan), valueSpan), keySize,
valueSize};
const ByteSpan headerSpan = angle::byte_span_from_ref(header);
std::vector<uint8_t> contents;
contents.reserve(fileSize);
contents.insert(contents.end(), headerSpan.begin(), headerSpan.end());
contents.insert(contents.end(), keySpan.begin(), keySpan.end());
contents.insert(contents.end(), valueSpan.begin(), valueSpan.end());
// Write it out. On failure, don't leave a partial file behind for a later open to find.
const std::filesystem::path path = EntryPath(state, hash);
std::ofstream file(path, std::ios::binary | std::ios::trunc);
file.write(reinterpret_cast<const char *>(contents.data()),
static_cast<std::streamsize>(contents.size()));
file.close();
if (!file)
{
ERR() << "TestBlobCache: Failed to write " << path.string();
std::error_code ec;
std::filesystem::remove(path, ec);
return;
}
// Track the new entry as the most recently used, on disk as well as in memory
RecordUseLocked(state, path);
state.entries[hash] = {keyBytes, size, ++state.accessClock};
state.stats.totalSize += fileSize;
++state.stats.stores;
state.stats.storedBytes += size;
}
// The EGL_ANDROID_blob_cache get callback, following MultifileBlobCache::get(). ANGLE asks for the
// size with no buffer, then again with a buffer of that size. Only the call that returns the value
// counts as a hit.
EGLsizeiANDROID GetBlob(const void *key,
EGLsizeiANDROID keySize,
void *value,
EGLsizeiANDROID valueSize)
{
State &state = GetState();
// Guards the entries, the files and the statistics.
std::lock_guard<std::mutex> lock(state.mutex);
// Without a directory there is nothing to find
if (state.directory.empty())
{
WarnNoDirectoryLocked(state);
return 0;
}
// Arguments ANGLE should never pass
if (key == nullptr || keySize <= 0)
{
ERR() << "TestBlobCache: Invalid lookup, key " << key << " keySize " << keySize;
return 0;
}
// SAFETY: EGL_ANDROID_blob_cache passes |keySize| bytes at |key|, checked above.
const ByteSpan keySpan = ANGLE_UNSAFE_BUFFERS(
ByteSpan(static_cast<const uint8_t *>(key), static_cast<size_t>(keySize)));
// Look up the entry by the key's hash
auto it = state.entries.find(HashKey(keySpan));
if (it == state.entries.end())
{
++state.stats.misses;
return 0;
}
// If the caller's buffer can't hold the value, this is the size query: just report the size
const size_t size = it->second.valueSize;
if (value == nullptr || valueSize < 0 || static_cast<size_t>(valueSize) < size)
{
return static_cast<EGLsizeiANDROID>(size);
}
// Read the whole file, as Android does before returning an entry it read from disk
const std::filesystem::path path = EntryPath(state, it->first);
std::vector<uint8_t> contents(EntryFileSize(it->second.keySize, size));
std::ifstream file(path, std::ios::binary);
file.read(reinterpret_cast<char *>(contents.data()),
static_cast<std::streamsize>(contents.size()));
// Close the file before anything below removes it. Windows can't delete a file that is open.
const bool readSucceeded = static_cast<bool>(file);
file.close();
if (!readSucceeded)
{
ERR() << "TestBlobCache: Failed to read " << path.string() << ", removing the entry";
RemoveEntryLocked(state, it);
++state.stats.misses;
return 0;
}
// Check that the file still holds what the cache stored: the header must match the tracked
// sizes and the CRC must match the contents
const auto [headerSpan, keyAndValue] = ByteSpan(contents).split_at(sizeof(EntryHeader));
EntryHeader header = {};
angle::byte_span_from_ref(header).copy_from(headerSpan);
if (header.magic != kEntryMagic ||
header.keySize != static_cast<EGLsizeiANDROID>(it->second.keySize) ||
header.valueSize != static_cast<EGLsizeiANDROID>(size) ||
header.crc != CRC32(0, keyAndValue))
{
WARN() << "TestBlobCache: " << path.string()
<< " was changed behind the cache's back, removing the entry";
RemoveEntryLocked(state, it);
++state.stats.misses;
return 0;
}
// The entry is keyed by hash, so make sure it holds this key and not another with the same
// hash
const auto [storedKey, storedValue] = keyAndValue.split_at(it->second.keySize);
if (storedKey != keySpan)
{
WARN() << "TestBlobCache: Key doesn't match the entry stored for hash " << it->first
<< ", a hash collision or modified file";
++state.stats.misses;
return 0;
}
// Return the value and mark the entry as the most recently used, on disk as well as in memory.
// SAFETY: the caller's buffer holds |valueSize| bytes at |value|, at least |size| as checked
// above.
const angle::Span<uint8_t> valueOut =
ANGLE_UNSAFE_BUFFERS(angle::Span<uint8_t>(static_cast<uint8_t *>(value), size));
valueOut.copy_from(storedValue);
it->second.lastAccess = ++state.accessClock;
RecordUseLocked(state, path);
++state.stats.hits;
return static_cast<EGLsizeiANDROID>(size);
}
// Uses |dir| for storage, creating it if needed, and reads the entries already in it. Statistics
// start from zero, as in a new process.
void OpenDirectoryLocked(State &state, const std::string &dir)
{
state.entries.clear();
state.stats = {};
state.accessClock = 0;
state.lastUseTime = {};
state.warnedNoDirectory = false;
state.directory = dir;
if (state.directory.empty())
{
return;
}
// Every filesystem call takes an error_code: ANGLE builds without exceptions, so the throwing
// overloads would abort.
std::error_code ec;
std::filesystem::create_directories(state.directory, ec);
if (ec)
{
ERR() << "TestBlobCache: Failed to create " << state.directory.string() << ": "
<< ec.message();
return;
}
// Read the entries already there, least recently used first, so that eviction order survives
// a restart. Each is {last use, hash, key size, value size}.
// |ec| tracks listing the directory, and |entryEc| each entry, so that a problem with one
// entry doesn't end the listing.
std::vector<std::tuple<std::filesystem::file_time_type, uint32_t, size_t, size_t>> found;
std::filesystem::directory_iterator it(state.directory, ec);
for (std::filesystem::directory_iterator end; !ec && it != end; it.increment(ec))
{
std::error_code entryEc;
// Files that aren't named like entries aren't ours, so leave them alone
uint32_t hash = 0;
if (!it->is_regular_file(entryEc) || !ParseEntryName(it->path().filename().string(), &hash))
{
continue;
}
// Read the header and check that the file is the size it describes. Unlike Android, the
// CRC isn't checked until the entry is read.
EntryHeader header = {};
std::ifstream file(it->path(), std::ios::binary);
file.read(reinterpret_cast<char *>(&header), sizeof(header));
const uintmax_t fileSize = it->file_size(entryEc);
if (!file || entryEc || header.magic != kEntryMagic || header.keySize <= 0 ||
header.valueSize < 0 ||
EntryFileSize(static_cast<size_t>(header.keySize),
static_cast<size_t>(header.valueSize)) != fileSize)
{
WARN() << "TestBlobCache: Skipping " << it->path().string()
<< ", which isn't a valid entry";
continue;
}
// Its write time is when it was last used
const std::filesystem::file_time_type lastUse = it->last_write_time(entryEc);
if (entryEc)
{
WARN() << "TestBlobCache: Skipping " << it->path().string()
<< ", unable to read its last use: " << entryEc.message();
continue;
}
found.emplace_back(lastUse, hash, static_cast<size_t>(header.keySize),
static_cast<size_t>(header.valueSize));
}
if (ec)
{
ERR() << "TestBlobCache: Failed to list " << state.directory.string() << ": "
<< ec.message();
}
// Track them oldest first, so the access clock preserves their order. Later uses are recorded
// after the newest of them.
std::sort(found.begin(), found.end());
for (const auto &[lastUse, hash, keySize, valueSize] : found)
{
state.entries[hash] = {keySize, valueSize, ++state.accessClock};
state.stats.totalSize += EntryFileSize(keySize, valueSize);
state.lastUseTime = lastUse;
}
INFO() << "TestBlobCache: Opened " << state.directory.string() << " with "
<< state.entries.size() << " entries and " << state.stats.totalSize << " bytes";
}
} // namespace
// Creates an empty directory that no other process is using, for InitTestBlobCache(). Bots
// run test processes side by side, each building its own fixtures, so a fixed name would let one
// process delete the entries another is still reading. The caller removes the directory when done.
// A process has one at a time: calling this again returns the same directory, emptied. Returns an
// empty string, which leaves the cache disabled, if no directory can be created.
std::string CreateTestBlobCacheDirectory()
{
// Every filesystem call takes an error_code: ANGLE builds without exceptions, so the throwing
// overloads would abort.
std::error_code ec;
// Find the temporary directory, and make sure it exists. On Android it's TMPDIR, which the
// framework points at the app's cache directory, and which may not have been created yet.
const Optional<std::string> tempDir = angle::GetTempDirectory();
if (!tempDir.valid())
{
ERR() << "TestBlobCache: No temporary directory for the cache, is TMPDIR set?";
return "";
}
std::filesystem::create_directories(tempDir.value(), ec);
if (ec)
{
ERR() << "TestBlobCache: Failed to create the temporary directory " << tempDir.value()
<< ": " << ec.message();
return "";
}
// Name the directory after this process, which no other live process shares. This allows one
// directory per process at a time, as the cache itself is one per process.
const std::string dir = (std::filesystem::path(tempDir.value()) /
(".angle_blob_cache." + std::to_string(GetCurrentProcessID())))
.string();
if (std::filesystem::create_directory(dir, ec))
{
return dir;
}
if (ec && ec != std::errc::file_exists)
{
ERR() << "TestBlobCache: Failed to create " << dir << ": " << ec.message();
return "";
}
// It's already there, left by a process that didn't clean up: a dead one whose ID was reused,
// or an earlier fixture in this one. Nothing is using it, so start over. That something
// didn't clean up is worth knowing about.
WARN() << "TestBlobCache: " << dir << " was left behind by an earlier run, removing it";
std::filesystem::remove_all(dir, ec);
if (ec || !std::filesystem::create_directory(dir, ec))
{
ERR() << "TestBlobCache: Failed to recreate " << dir << ": " << ec.message();
return "";
}
return dir;
}
// Stops using the cache and removes |dir|, which CreateTestBlobCacheDirectory() returned. The
// cache forgets the directory first, so that a Display that is still alive stores nothing into it:
// a store into a removed directory would fail and log an error.
void RemoveTestBlobCacheDirectory(const std::string &dir)
{
ResetTestBlobCache();
// An empty name means no directory was created
if (dir.empty())
{
return;
}
// The error_code overload: ANGLE builds without exceptions, so the throwing one would abort.
std::error_code ec;
std::filesystem::remove_all(dir, ec);
if (ec)
{
WARN() << "TestBlobCache: Failed to remove " << dir << ": " << ec.message();
}
}
// Sets up the cache for a new Display, as Android does for a new process: applies |limits|, reads
// the entries already in |dir|, creating it if needed, and hands the callbacks to |display| through
// the caller's eglSetBlobCacheFuncsANDROID. Called again with the same directory for a later
// Display, it is what a process restart looks like to the cache. Statistics start from zero.
//
// The caller supplies the entry point because how to get one depends on how the binary reaches
// ANGLE. EGL only accepts the callbacks once per display, so a Display that already has them
// raises EGL_BAD_PARAMETER and keeps using them.
void InitTestBlobCache(EGLDisplay display,
PFNEGLSETBLOBCACHEFUNCSANDROIDPROC setFuncs,
const std::string &dir,
const TestBlobCacheLimits &limits)
{
// Open the directory before the callbacks are handed out, so that they never see a stale one
{
State &state = GetState();
// Guards the entries, the files, the limits and the statistics.
std::lock_guard<std::mutex> lock(state.mutex);
state.limits = limits;
OpenDirectoryLocked(state, dir);
}
if (setFuncs == nullptr)
{
ERR() << "TestBlobCache: No eglSetBlobCacheFuncsANDROID entry point, the cache won't be "
"used";
return;
}
setFuncs(display, SetBlob, GetBlob);
}
// Returns the statistics since the directory was last set, and what the cache holds now.
TestBlobCacheStats GetTestBlobCacheStats()
{
State &state = GetState();
// Updated by the callbacks on any thread.
std::lock_guard<std::mutex> lock(state.mutex);
TestBlobCacheStats stats = state.stats;
stats.entries = state.entries.size();
return stats;
}
// Forgets the directory, the entries and the statistics, and restores the default limits. Files
// on disk are left alone.
void ResetTestBlobCache()
{
State &state = GetState();
// Guards everything the callbacks touch.
std::lock_guard<std::mutex> lock(state.mutex);
state.directory.clear();
state.entries.clear();
state.limits = {};
state.stats = {};
state.accessClock = 0;
state.lastUseTime = {};
state.warnedNoDirectory = false;
}