// Copyright (C) 2026 half144 and PS5 React contributors // SPDX-License-Identifier: GPL-3.0-or-later // Additional attribution term: see LICENSE-ATTRIBUTION. #include "image_loader.hpp" #include "thread_name.hpp" #include "network.hpp" #ifdef PROSPERO #include "app_config.hpp" #endif #if !defined(PROSPERO) || PS5_REACT_NETWORKING #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // Several threads decode at once. stb_image's failure strings are not thread-safe (a global, or a // thread_local the title has never used, plus a static buffer it rewrites for unknown PNG chunks), so // they are compiled out, and running out of heap is told apart by counting failed allocations // instead. Its other globals are only read. namespace { // The hooks cannot tell which decode allocates: one that fails while this moved is treated as out of // memory and retried, which under heap pressure is right for any of them. std::atomic stb_failed_allocations{0}; void* stb_malloc(std::size_t size) { void* p = std::malloc(size); if (!p) ++stb_failed_allocations; return p; } void* stb_realloc(void* address, std::size_t size) { void* p = std::realloc(address, size); if (!p) ++stb_failed_allocations; return p; } } // namespace #define STBI_MALLOC stb_malloc #define STBI_REALLOC stb_realloc #define STBI_FREE std::free #define STBI_NO_THREAD_LOCALS #define STBI_NO_FAILURE_STRINGS #define STBI_NO_STDIO #define STBI_NO_HDR #define STBI_NO_LINEAR #define STBI_ONLY_JPEG #define STBI_ONLY_PNG #define STB_IMAGE_STATIC #define STB_IMAGE_IMPLEMENTATION #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wunused-function" #pragma clang diagnostic ignored "-Wsign-compare" #pragma clang diagnostic ignored "-Wunused-parameter" #pragma clang diagnostic ignored "-Wmissing-field-initializers" #pragma clang diagnostic ignored "-Wimplicit-fallthrough" #include "stb_image.h" #pragma clang diagnostic pop namespace images { namespace { using Clock = std::chrono::steady_clock; // Five attempts, 0.5 + 1 + 2 + 4 s apart: a console that briefly cannot connect (Wi-Fi waking, a // burst of handshakes refused) recovers instead of failing every image on screen at once. constexpr unsigned kConnections = 32, kAttempts = 5; // IP literals, so the DoH request itself needs no DNS. A network that blocks the first gets the second. constexpr std::array kDohURLs = {"https://1.1.1.1/dns-query", "https://8.8.8.8/dns-query"}; constexpr std::size_t kResolver = kDohURLs.size(); // A decode that ran out of heap is tried again 1 and 2 s later, from the cached bytes. constexpr unsigned kDecodeAttempts = 3; // A screen of evicted covers reloads from the disk cache at 8-10 ms of decode each on the PS5. Its // title sees 16 CPUs and a commit raster takes 9 (eight render workers); four decoders fit beside // them. A fixed count keeps the preview's behaviour the console's. constexpr unsigned kDecoders = 4; // A decode holds up to about 8 bytes per source pixel: stb_image's buffers (4.5 for JPEG, 8 for PNG), // then its output and the resampled pixels. Decodes running together stay within what one image at // the source limit took when a single thread decoded; one always runs, whatever it holds. constexpr std::size_t kDecodeBytesPerPixel = 8; constexpr std::size_t kDecodeBudget = kMaxSourcePixels * kDecodeBytesPerPixel; // While a download runs it holds up to 64 connections and their TLS state; images take fewer. constexpr unsigned kDownloadConnections = 8; // Per origin, on reused keep-alive connections (the console's curl has no HTTP/2): a grid of 30 Sony // covers opened 30 TLS handshakes to one host at once. constexpr long kHostConnections = 8; // An element's image goes to the network only once it has been wanted this long, like a browser's // lazy loading: a held key scrolls a card past in about 50 ms and it is released before it fetches. // Images in the decoded, encoded or disk cache skip the wait. constexpr auto kDwell = std::chrono::milliseconds(120); std::atomic cache_bytes{kCacheBytes}; std::atomic holds{0}; std::atomic heap_probe{nullptr}; Heap heap() { const auto probe = heap_probe.load(); return probe ? probe() : Heap{}; } bool low_memory() { const Heap h = heap(); return holds > 0 || (h.size && h.live > h.size / 100 * kPressurePercent); } std::atomic deferred_until{0}; // New fetches wait while encoded bytes held anywhere (transfers, the decode queue, the encoded // cache) exceed this; transfers in flight still finish. Responses are rarely over 2 MiB. constexpr std::size_t kEncodedBudget = kEncodedCacheBytes + 12 * 1024 * 1024; constexpr std::size_t kMinBodyCapacity = 64 * 1024; std::atomic encoded_bytes{0}, decoded_bytes{0}; // Encoded bytes in one malloc'd block: running out of heap fails one load (and retries it) instead // of aborting the title the way a throwing std::string allocation does without exceptions. struct Bytes { Bytes() = default; Bytes(Bytes&& other) noexcept { *this = std::move(other); } Bytes& operator=(Bytes&& other) noexcept { clear(); std::swap(data, other.data); std::swap(size, other.size); std::swap(capacity, other.capacity); return *this; } ~Bytes() { clear(); } bool reserve(std::size_t want) { if (want <= capacity) return true; void* grown = std::realloc(data, want); if (!grown) return false; encoded_bytes += want - capacity; data = static_cast(grown); capacity = want; return true; } void clear() { std::free(data); encoded_bytes -= capacity; data = nullptr; size = capacity = 0; } char* data = nullptr; std::size_t size = 0, capacity = 0; }; using Body = std::shared_ptr; // Telemetry for the host's memory log. Counters are atomics; origins are guarded by their mutex. struct Counters { std::atomic loads{0}, decoded_hits{0}, encoded_hits{0}, disk_hits{0}, fetches{0}, decodes{0}, decode_us{0}, cancelled{0}, aborted{0}, retries{0}, failed{0}, shown{0}, shown_ms{0}, shown_max_ms{0}; } counters; // `last`: the latest failure's reason, so the stats line says why an origin fails. struct Origin { std::string host, last; std::uint64_t requests = 0, failures = 0, ms = 0, bytes = 0; }; std::mutex origins_mutex; std::vector origins; std::string host_of(const std::string& url) { const std::size_t start = url.find("://"); if (start == std::string::npos) return url; const std::size_t end = url.find_first_of("/?#", start + 3); return url.substr(start + 3, end == std::string::npos ? std::string::npos : end - start - 3); } void count_request(const std::string& url, const std::string& failure, std::uint64_t ms, std::size_t bytes) { std::lock_guard lock(origins_mutex); const std::string host = host_of(url); auto it = std::find_if(origins.begin(), origins.end(), [&](const Origin& o) { return o.host == host; }); if (it == origins.end()) { origins.push_back({host, {}}); it = origins.end() - 1; } ++it->requests; it->ms += ms; it->bytes += bytes; if (!failure.empty()) { ++it->failures; it->last = failure; } } constexpr std::uint64_t kDrawn = std::uint64_t{1} << 62; // Warming fills the disk cache in the background: a few connections, only while no load waits, and // only files small enough that many fit. constexpr unsigned kWarmConnections = 4; constexpr std::size_t kWarmMaxBytes = 512 * 1024; struct Entry { ~Entry() { free_pixels(); } void free_pixels() { if (pixels) decoded_bytes -= static_cast(width) * height * 4; std::free(pixels); pixels = nullptr; } std::uint32_t id = 0; std::string key, url; int box_width = 0, box_height = 0; Fit fit = Fit::cover; std::atomic cancelled{false}; // Fetch order, highest first: kDrawn plus the request sequence while an element draws it (the image // that came on screen last is the one looked at), else the sequence of its latest prefetch. A held // key scrolls past hundreds of covers; serving them oldest first left the ones on screen waiting. std::atomic rank{0}; // Written by the workers before the entry is handed to poll() under the service mutex. Body body; std::size_t decode_cost = 0; std::uint32_t* pixels = nullptr; int width = 0, height = 0; bool opaque = true; std::int32_t color = -1; std::string error; unsigned attempts = 0; Clock::time_point retry_at{}, dwell_until{}; // Render thread only. Clock::time_point requested{}; unsigned refs = 0, drawers = 0; std::uint64_t used = 0, prefetched = 0; bool reported = false; }; using EntryPtr = std::shared_ptr; // One request per URL: loads of the same URL for other boxes join it and decode its bytes. struct Transfer { CURL* curl = nullptr; std::string url; Bytes body; std::vector entries; bool overflow = false, out_of_memory = false; // Started by warm(): written to the disk cache; loads of its URL join it. bool warm = false; Clock::time_point started{}; // How it reached its host: the console's resolver (kResolver), kDohURLs[doh], or the address `pin`. std::size_t doh = 0; const curl_slist* pin = nullptr; }; // Recently fetched encoded bytes by URL, so another box for the same image decodes without a fetch. struct Encoded { std::string url; Body body; std::uint64_t used = 0; }; std::string message(const Entry& entry, const std::string& reason) { return "Image.load " + entry.url + ": " + reason; } // Box filter over a source span: output i averages source [i*scale, (i+1)*scale), partial // pixels weighted by coverage. The scale is at least 1; images are never enlarged here. struct Taps { std::vector first, count; std::vector weights; Taps(int offset, int source, int target) { const double scale = static_cast(source) / target; for (int i = 0; i < target; ++i) { const double a = i * scale, b = std::min(source, (i + 1) * scale); const int begin = static_cast(a), end = std::min(source, static_cast(std::ceil(b))); first.push_back(offset + begin); count.push_back(std::max(end - begin, 1)); for (int j = begin; j < begin + count.back(); ++j) weights.push_back(static_cast((std::min(b, j + 1) - std::max(a, j)) / scale)); } } }; struct Plan { int x = 0, y = 0, width = 0, height = 0, out_width = 0, out_height = 0; }; // The source crop and output size for a box: cover crops to the box's aspect ratio, contain keeps // the whole image; both shrink to the box but never enlarge, leaving upscaling to the engine. Plan plan(int width, int height, int box_width, int box_height, Fit fit) { Plan p{0, 0, width, height, width, height}; if (fit == Fit::none) return p; if (fit == Fit::cover) { if (static_cast(width) * box_height > static_cast(height) * box_width) p.width = std::max(1, static_cast(std::lround(static_cast(height) * box_width / box_height))); else p.height = std::max(1, static_cast(std::lround(static_cast(width) * box_height / box_width))); p.x = (width - p.width) / 2; p.y = (height - p.height) / 2; p.out_width = p.width; p.out_height = p.height; if (p.width > box_width) { p.out_width = box_width; p.out_height = box_height; } } else if (fit == Fit::contain) { const double scale = std::min({1.0, static_cast(box_width) / width, static_cast(box_height) / height}); p.out_width = std::max(1, static_cast(std::lround(width * scale))); p.out_height = std::max(1, static_cast(std::lround(height * scale))); } else { p.out_width = std::min(width, box_width); p.out_height = std::min(height, box_height); } return p; } // Resamples the planned crop of 8-bit RGB or RGBA rows into premultiplied ARGB8888. bool resample(const unsigned char* source, int width, int channels, const Plan& p, Entry& entry) { const std::size_t count = static_cast(p.out_width) * p.out_height; auto* out = static_cast(std::malloc(count * sizeof(std::uint32_t))); if (!out) return false; const Taps columns(p.x, p.width, p.out_width), rows(p.y, p.height, p.out_height); std::vector column_weight(p.out_width); for (int x = 0, w = 0; x < p.out_width; w += columns.count[x], ++x) column_weight[x] = static_cast(w); std::vector line(static_cast(p.out_width) * 4), sum(line.size()); bool opaque = true; for (int y = 0, row_weight = 0; y < p.out_height; row_weight += rows.count[y], ++y) { std::fill(sum.begin(), sum.end(), 0.0f); for (int k = 0; k < rows.count[y]; ++k) { const float wy = rows.weights[static_cast(row_weight + k)]; const unsigned char* row = source + static_cast(rows.first[y] + k) * width * channels; for (int x = 0; x < p.out_width; ++x) { float r = 0, g = 0, b = 0, a = 0; const float* weight = &columns.weights[column_weight[x]]; const unsigned char* px = row + static_cast(columns.first[x]) * channels; for (int i = 0; i < columns.count[x]; ++i, px += channels) { const float alpha = channels == 4 ? px[3] : 255.0f, w = weight[i] * alpha / 255.0f; r += w * px[0]; g += w * px[1]; b += w * px[2]; a += weight[i] * alpha; } float* s = &sum[static_cast(x) * 4]; s[0] += wy * a; s[1] += wy * r; s[2] += wy * g; s[3] += wy * b; } } std::uint32_t* target = out + static_cast(y) * p.out_width; for (int x = 0; x < p.out_width; ++x) { const float* s = &sum[static_cast(x) * 4]; const auto a = static_cast(std::clamp(std::lround(s[0]), 0L, 255L)); const auto channel = [&](float value) { return std::min(static_cast(std::clamp(std::lround(value), 0L, 255L)), a); }; opaque = opaque && a == 255; target[x] = a << 24 | channel(s[1]) << 16 | channel(s[2]) << 8 | channel(s[3]); } } entry.pixels = out; entry.width = p.out_width; entry.height = p.out_height; entry.opaque = opaque; decoded_bytes += count * sizeof(std::uint32_t); return true; } // The most prominent saturated hue, raised to full brightness and to at least 85% saturation: what a // light bar or accent can show of the art (an LED shows a pale tint as a washed-out colour). -1 when // the art has no vivid colour. Samples at most about 16k pixels. std::int32_t vivid_color(const std::uint32_t* pixels, int width, int height) { constexpr int bins = 24; float weight[bins] = {}, red[bins] = {}, green[bins] = {}, blue[bins] = {}; const int count = width * height, step = std::max(1, count / 16384); int sampled = 0; for (int i = 0; i < count; i += step) { ++sampled; const std::uint32_t pixel = pixels[i]; const float alpha = static_cast(pixel >> 24); if (alpha < 128) continue; const float r = ((pixel >> 16) & 255) / alpha, g = ((pixel >> 8) & 255) / alpha, b = (pixel & 255) / alpha; const float high = std::max({r, g, b}), low = std::min({r, g, b}), range = high - low; if (high < 0.25f || range < 0.25f * high) continue; float hue = high == r ? (g - b) / range : high == g ? 2 + (b - r) / range : 4 + (r - g) / range; if (hue < 0) hue += 6; const int bin = std::min(bins - 1, static_cast(hue / 6 * bins)); weight[bin] += range; // Weighted by saturation, so the purest pixels of a hue set its colour rather than its pale edges. red[bin] += r * range; green[bin] += g * range; blue[bin] += b * range; } const int best = static_cast(std::max_element(weight, weight + bins) - weight); if (weight[best] < 0.01f * sampled) return -1; const float peak = std::max({red[best], green[best], blue[best]}); const float floor = std::min({red[best], green[best], blue[best]}), saturation = (peak - floor) / peak; // Scaling each channel's distance below the peak keeps the hue while raising the saturation. const float boost = std::max(1.0f, 0.85f / saturation); const auto channel = [&](float value) { return static_cast(std::lround(std::max(0.0f, peak - (peak - value) * boost) / peak * 255)); }; return channel(red[best]) << 16 | channel(green[best]) << 8 | channel(blue[best]); } // Returns false when the heap ran out, so the load can be tried again later. bool decode(Entry& entry) { const auto* bytes = reinterpret_cast(entry.body->data); const int size = static_cast(entry.body->size); int width = 0, height = 0, components = 0; const unsigned failed_allocations = stb_failed_allocations; const auto out_of_memory = [&] { return stb_failed_allocations != failed_allocations; }; bool retry = false; if (!stbi_info_from_memory(bytes, size, &width, &height, &components)) { retry = out_of_memory(); entry.error = message(entry, retry ? "out of memory to decode" : "not a decodable JPEG or PNG"); } else if (static_cast(width) * height > kMaxSourcePixels) { entry.error = message(entry, std::to_string(width) + "x" + std::to_string(height) + " exceeds the 5-megapixel source limit"); } if (!entry.error.empty()) { entry.body.reset(); return !retry; } const int channels = components == 2 || components == 4 ? 4 : 3; const Plan p = plan(width, height, entry.box_width, entry.box_height, entry.fit); const std::size_t need = static_cast(width) * height * 8 + static_cast(p.out_width) * p.out_height * 4; if (const Heap h = heap(); h.size && h.live + need > h.size / 100 * kCeilingPercent) { entry.body.reset(); entry.error = message(entry, "out of memory to decode (heap near full)"); return false; } stbi_uc* source = stbi_load_from_memory(bytes, size, &width, &height, &components, channels); entry.body.reset(); if (!source) { retry = out_of_memory(); entry.error = message(entry, retry ? "out of memory to decode" : "decode failed"); return !retry; } const bool resampled = resample(source, width, channels, p, entry); ++counters.decodes; stbi_image_free(source); if (!resampled) { entry.error = message(entry, "out of memory for decoded pixels"); return false; } entry.color = vivid_color(entry.pixels, entry.width, entry.height); return true; } // Heap a decode of `body` holds at most, from its header; 0 when it fails before allocating. std::size_t decode_cost(const Bytes& body) { int width = 0, height = 0, components = 0; if (!stbi_info_from_memory(reinterpret_cast(body.data), static_cast(body.size), &width, &height, &components)) return 0; const auto pixels = static_cast(width) * height; return pixels > kMaxSourcePixels ? 0 : static_cast(pixels) * kDecodeBytesPerPixel; } // Encoded responses kept on disk between launches, keyed by a hash of the URL. Each file holds a // header (magic, fetch time, URL) and the body; the fetch thread alone touches the directory. class DiskCache { public: void open(const std::string& directory) { directory_ = directory; if (directory_.empty() || !make_directories(directory_)) { directory_.clear(); return; } files_.clear(); total_ = 0; if (DIR* dir = opendir(directory_.c_str())) { while (dirent* item = readdir(dir)) { struct stat info; if (item->d_name[0] == '.' || stat(path(item->d_name).c_str(), &info) != 0 || !S_ISREG(info.st_mode)) continue; files_.push_back({item->d_name, static_cast(info.st_size), static_cast(info.st_mtime)}); total_ += static_cast(info.st_size); } closedir(dir); } trim(); } bool has(const std::string& url) { return !directory_.empty() && find(key(url)); } // Null when missing, stale, corrupt or when the heap cannot hold it. Body read(const std::string& url) { if (directory_.empty()) return nullptr; const std::string name = key(url); File* file = find(name); if (!file) return nullptr; Header header{}; const std::size_t start = sizeof header + url.size(); if (file->size < start || file->size - start > kMaxEncodedBytes) { remove(name); return nullptr; } const int fd = ::open(path(name).c_str(), O_RDONLY); if (fd < 0) return nullptr; std::string stored(url.size(), '\0'); const bool valid = ::read(fd, &header, sizeof header) == static_cast(sizeof header) && !std::memcmp(header.magic, kMagic, sizeof header.magic) && header.url_size == url.size() && ::read(fd, stored.data(), stored.size()) == static_cast(stored.size()) && stored == url && std::time(nullptr) - header.fetched <= kMaxAgeSeconds; Bytes body; const std::size_t size = file->size - start; const bool held = valid && body.reserve(std::max(size, 1)); const bool whole = held && ::read(fd, body.data, size) == static_cast(size); close(fd); if (valid && !held) return nullptr; if (!whole) { remove(name); return nullptr; } body.size = size; file->used = std::time(nullptr); utimes(path(name).c_str(), nullptr); return std::make_shared(std::move(body)); } void write(const std::string& url, const Bytes& body) { if (directory_.empty()) return; const std::string name = key(url), temporary = path(name + ".tmp"); Header header{}; std::memcpy(header.magic, kMagic, sizeof header.magic); header.fetched = std::time(nullptr); header.url_size = static_cast(url.size()); const int fd = ::open(temporary.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600); if (fd < 0) return; const bool ok = ::write(fd, &header, sizeof header) == static_cast(sizeof header) && ::write(fd, url.data(), url.size()) == static_cast(url.size()) && ::write(fd, body.data, body.size) == static_cast(body.size); if (close(fd) != 0 || !ok || rename(temporary.c_str(), path(name).c_str()) != 0) { unlink(temporary.c_str()); return; } const std::size_t size = sizeof header + url.size() + body.size; if (File* old = find(name)) { total_ -= old->size; old->size = size; old->used = header.fetched; } else files_.push_back({name, size, header.fetched}); total_ += size; trim(); } private: static constexpr char kMagic[8] = {'P', '5', 'R', 'I', 'M', 'G', '0', '1'}; static constexpr std::int64_t kMaxAgeSeconds = 7 * 24 * 3600; struct Header { char magic[8]; std::int64_t fetched; std::uint32_t url_size, reserved; }; struct File { std::string name; std::size_t size; std::int64_t used; }; static std::string key(const std::string& url) { std::uint64_t hash = 14695981039346656037ULL; for (unsigned char c : url) hash = (hash ^ c) * 1099511628211ULL; char text[17]; std::snprintf(text, sizeof text, "%016llx", static_cast(hash)); return text; } static bool make_directories(const std::string& directory) { for (std::size_t at = 1; at != std::string::npos;) { at = directory.find('/', at + 1); const std::string part = directory.substr(0, at); if (mkdir(part.c_str(), 0700) != 0 && errno != EEXIST) return false; } return true; } std::string path(const std::string& name) const { return directory_ + "/" + name; } File* find(const std::string& name) { for (File& file : files_) if (file.name == name) return &file; return nullptr; } void remove(const std::string& name) { unlink(path(name).c_str()); for (auto it = files_.begin(); it != files_.end(); ++it) if (it->name == name) { total_ -= it->size; files_.erase(it); return; } } // Least recently used files go first once the directory is over its cap. void trim() { while (total_ > kDiskCacheBytes && !files_.empty()) { auto oldest = std::min_element(files_.begin(), files_.end(), [](const File& a, const File& b) { return a.used < b.used; }); remove(std::string(oldest->name)); } } std::string directory_; std::vector files_; std::size_t total_ = 0; }; bool spawn(pthread_t& thread, void* (*body)(void*), void* data) { pthread_attr_t attributes; if (pthread_attr_init(&attributes) != 0) return false; const int stack = pthread_attr_setstacksize(&attributes, 1024 * 1024); const int result = stack ? stack : pthread_create(&thread, &attributes, body, data); pthread_attr_destroy(&attributes); return result == 0; } class Service { public: bool start(const std::string& cache_directory) { if (running_) return true; cache_directory_ = cache_directory; stopping_ = false; multi_ = curl_multi_init(); if (!multi_ || curl_multi_setopt(multi_, CURLMOPT_MAX_HOST_CONNECTIONS, kHostConnections) != CURLM_OK) { stop(nullptr); return false; } for (Transfer& t : transfers_) if (!(t.curl = curl_easy_init())) { stop(nullptr); return false; } fetch_started_ = spawn(fetch_thread_, +[](void* p)->void* { static_cast(p)->fetch_loop(); return nullptr; }, this); while (fetch_started_ && decoders_ < kDecoders && spawn(decode_threads_[decoders_], +[](void* p)->void* { static_cast(p)->decode_loop(); return nullptr; }, this)) ++decoders_; if (decoders_ < kDecoders) { stop(nullptr); return false; } running_ = true; return true; } void stop(void (*evict)(std::uint32_t)) { for (const auto& [id, entry] : by_id_) if (entry->reported && entry->pixels) evict(id); { std::lock_guard lock(mutex_); stopping_ = true; } wake_.notify_all(); if (multi_) curl_multi_wakeup(multi_); if (fetch_started_) pthread_join(fetch_thread_, nullptr); for (unsigned i = 0; i < decoders_; ++i) pthread_join(decode_threads_[i], nullptr); fetch_started_ = false; decoders_ = 0; for (Transfer& t : transfers_) { if (t.curl) curl_easy_cleanup(t.curl); t = Transfer{}; } if (multi_) curl_multi_cleanup(multi_); multi_ = nullptr; for (curl_slist* pin : doh_pins_) curl_slist_free_all(pin); doh_pins_.clear(); doh_hosts_.clear(); fetches_.clear(); decodes_.clear(); finished_.clear(); by_key_.clear(); by_id_.clear(); encoded_.clear(); running_ = false; } std::uint32_t load(const std::string& url, int width, int height, Fit fit, bool prefetch, Result& result, std::string& error) { if (!running_) { error = "Image.load " + url + ": networking is not available"; return 0; } const std::string key = std::to_string(static_cast(fit)) + ' ' + std::to_string(width) + 'x' + std::to_string(height) + ' ' + url; auto found = by_key_.find(key); EntryPtr entry; if (found != by_key_.end()) { entry = found->second; if (entry->reported && entry->pixels) ++counters.decoded_hits; } else { ++counters.loads; entry = std::make_shared(); entry->id = next_id_++; entry->key = key; entry->url = url; entry->box_width = width; entry->box_height = height; entry->fit = fit; entry->requested = Clock::now(); if (!prefetch) entry->dwell_until = entry->requested + kDwell; by_key_.emplace(key, entry); by_id_.emplace(entry->id, entry); { std::lock_guard lock(mutex_); fetches_.push_back(entry); } curl_multi_wakeup(multi_); } ++entry->refs; entry->used = ++clock_; if (prefetch) entry->prefetched = clock_; else ++entry->drawers; entry->rank = entry->drawers ? kDrawn | clock_ : entry->prefetched; result = report(*entry); return entry->id; } void warm(std::vector urls) { if (!running_) return; { std::lock_guard lock(mutex_); warms_.assign(std::make_move_iterator(urls.begin()), std::make_move_iterator(urls.end())); } curl_multi_wakeup(multi_); } void discard(std::uint32_t id, const std::string& reason) { const auto found = by_id_.find(id); if (found == by_id_.end()) return; Entry& entry = *found->second; entry.free_pixels(); entry.error = message(entry, reason); } void release(std::uint32_t id, bool prefetch) { const auto found = by_id_.find(id); if (found == by_id_.end()) return; const EntryPtr entry = found->second; if (!prefetch && entry->drawers && !--entry->drawers) entry->rank = entry->prefetched; if (--entry->refs) return; entry->used = ++clock_; if (entry->reported && entry->pixels) { over_budget_ = true; return; } entry->cancelled = true; forget(*entry); if (!entry->reported) { ++counters.cancelled; curl_multi_wakeup(multi_); } } std::vector poll(void (*evict)(std::uint32_t)) { std::vector done; { std::lock_guard lock(mutex_); done.swap(finished_); } std::vector out; for (const EntryPtr& entry : done) { if (entry->cancelled) continue; entry->reported = true; if (entry->pixels && entry->drawers) { // Time from the first request to pixels, for images an element is waiting to draw. const auto ms = static_cast( std::chrono::duration_cast(Clock::now() - entry->requested).count()); ++counters.shown; counters.shown_ms += ms; if (ms > counters.shown_max_ms) counters.shown_max_ms = ms; } out.push_back(report(*entry)); over_budget_ = over_budget_ || entry->pixels; } // A lower budget (memory held, heap pressure) trims at once; a higher one waits for new images. const std::size_t budget = low_memory() ? std::min(cache_bytes.load(), kLowCacheBytes) : cache_bytes.load(); const bool lowered = budget < budget_; budget_ = budget; if (over_budget_ || lowered) trim(evict); return out; } private: static std::size_t bytes(const Entry& entry) { return static_cast(entry.width) * entry.height * 4; } static Result report(const Entry& entry) { Result r; r.id = entry.id; if (!entry.reported) return r; r.failed = !entry.pixels; r.ready = !r.failed; r.error = entry.error; r.width = entry.width; r.height = entry.height; r.opaque = entry.opaque; r.pixels = entry.pixels; r.color = entry.color; return r; } void forget(const Entry& entry) { by_key_.erase(entry.key); by_id_.erase(entry.id); } // Evicts unused images, least recently used first, until the cache fits its budget. void trim(void (*evict)(std::uint32_t)) { over_budget_ = false; std::size_t total = 0, decoded = 0; for (const auto& [id, entry] : by_id_) if (entry->reported && entry->pixels) { total += bytes(*entry); ++decoded; } while (total > budget_ || decoded > kCacheEntries) { EntryPtr oldest; for (const auto& [id, entry] : by_id_) if (entry->reported && entry->pixels && !entry->refs && (!oldest || entry->used < oldest->used)) oldest = entry; if (!oldest) return; evict(oldest->id); total -= bytes(*oldest); --decoded; forget(*oldest); } } void finish(const EntryPtr& entry) { { std::lock_guard lock(mutex_); finished_.push_back(entry); } } // Grows the body to the announced length at once when there is one, else by doubling, never // past the limit. static std::size_t body(char* data, std::size_t size, std::size_t count, void* opaque) { auto& t = *static_cast(opaque); const std::size_t length = size * count; const std::size_t limit = t.entries.empty() ? kWarmMaxBytes : kMaxEncodedBytes; curl_off_t announced = -1; curl_easy_getinfo(t.curl, CURLINFO_CONTENT_LENGTH_DOWNLOAD_T, &announced); if (length > limit - t.body.size || (announced > 0 && static_cast(announced) > limit)) { t.overflow = true; return 0; } const std::size_t needed = t.body.size + length; if (needed > t.body.capacity) { const std::size_t want = announced > 0 && static_cast(announced) >= needed ? static_cast(announced) : std::max({needed, kMinBodyCapacity, t.body.capacity * 2}); if (!t.body.reserve(std::min(want, limit))) { t.out_of_memory = true; return 0; } } std::memcpy(t.body.data + t.body.size, data, length); t.body.size = needed; return length; } // Aborts a transfer once every load waiting for it has been released. static int progress(void* opaque, curl_off_t, curl_off_t, curl_off_t, curl_off_t) { const auto& t = *static_cast(opaque); const auto& entries = t.entries; if (t.warm && entries.empty()) return 0; return std::all_of(entries.begin(), entries.end(), [](const EntryPtr& e) { return e->cancelled.load(); }) ? 1 : 0; } void fail(const EntryPtr& entry, const std::string& reason) { entry->error = message(*entry, reason); finish(entry); } void decode_later(std::vector entries, const Body& body) { const std::size_t cost = decode_cost(*body); { std::lock_guard lock(mutex_); for (EntryPtr& entry : entries) { if (entry->cancelled) continue; entry->body = body; entry->decode_cost = cost; decodes_.push_back(std::move(entry)); } } wake_.notify_all(); } bool held(const std::string& url) const { return std::any_of(encoded_.begin(), encoded_.end(), [&](const Encoded& e) { return e.url == url; }); } Body cached(const std::string& url) { for (Encoded& e : encoded_) if (e.url == url) { e.used = ++fetch_clock_; return e.body; } return nullptr; } void remember(const std::string& url, Body body) { if (fit_encoded(body->size)) encoded_.push_back({url, std::move(body), ++fetch_clock_}); } // Drops the least recently used encoded bytes until `extra` more fit the budget; false when they cannot. bool fit_encoded(std::size_t extra) { const std::size_t limit = low_memory() ? kLowEncodedBytes : kEncodedCacheBytes; std::size_t total = extra; for (const Encoded& e : encoded_) total += e.body->size; while (total > limit && !encoded_.empty()) { auto oldest = std::min_element(encoded_.begin(), encoded_.end(), [](const Encoded& a, const Encoded& b) { return a.used < b.used; }); total -= oldest->body->size; encoded_.erase(oldest); } return total <= limit; } // Fetch thread: serves the entry from cached bytes, joins a transfer of its URL, or starts one. void start_fetch(EntryPtr entry, unsigned& active) { if (auto body = cached(entry->url)) { ++counters.encoded_hits; decode_later({std::move(entry)}, body); return; } if (auto body = disk_.read(entry->url)) { ++counters.disk_hits; remember(entry->url, body); decode_later({std::move(entry)}, body); return; } for (Transfer& t : transfers_) if (busy(t) && t.url == entry->url) { t.entries.push_back(std::move(entry)); return; } ++counters.fetches; Transfer* t = begin(entry->url, false); t->entries.push_back(std::move(entry)); if (!send(*t)) { for (const EntryPtr& e : t->entries) fail(e, "could not configure the HTTP transport"); t->entries.clear(); return; } ++active; } static bool busy(const Transfer& t) { return t.warm || !t.entries.empty(); } Transfer* begin(const std::string& url, bool warm) { Transfer* t = nullptr; for (Transfer& candidate : transfers_) if (!busy(candidate)) { t = &candidate; break; } t->url = url; t->body.clear(); t->overflow = t->out_of_memory = false; t->warm = warm; return t; } // Fetch thread: the next warm URL not already cached or in flight, on a connection of its own. void start_warm(unsigned& active, unsigned& warming) { while (true) { std::string url; { std::lock_guard lock(mutex_); if (warms_.empty()) return; url = std::move(warms_.front()); warms_.pop_front(); } if (cached(url) || disk_.has(url) || std::any_of(transfers_.begin(), transfers_.end(), [&](const Transfer& t) { return busy(t) && t.url == url; })) continue; Transfer* t = begin(url, true); if (!send(*t)) { t->warm = false; return; } ++active; ++warming; return; } } bool send(Transfer& transfer) { Transfer* t = &transfer; curl_easy_reset(t->curl); bool ok = network::configure_transport(t->curl, t->url, true); const auto set = [&](CURLoption option, auto value) { ok = ok && curl_easy_setopt(t->curl, option, value) == CURLE_OK; }; set(CURLOPT_TIMEOUT, 30L); t->doh = kResolver; t->pin = nullptr; if (const auto doh = doh_hosts_.find(host_of(t->url)); doh != doh_hosts_.end()) { t->doh = doh->second.server; t->pin = doh->second.pin; // Once a DoH load has connected, the rest go straight to its address: a DoH query per image made // a fresh install's first screen of covers take seconds. if (doh->second.pin) { set(CURLOPT_RESOLVE, doh->second.pin); } else { // The handle's cache still holds the blocked address, which a lookup would return before DoH. set(CURLOPT_DOH_URL, kDohURLs[doh->second.server]); set(CURLOPT_DNS_CACHE_TIMEOUT, 0L); } } set(CURLOPT_WRITEFUNCTION, body); set(CURLOPT_WRITEDATA, t); set(CURLOPT_XFERINFOFUNCTION, progress); set(CURLOPT_XFERINFODATA, t); set(CURLOPT_NOPROGRESS, 0L); set(CURLOPT_PRIVATE, t); t->started = Clock::now(); return ok && curl_multi_add_handle(multi_, t->curl) == CURLM_OK; } void complete(Transfer& t, CURLcode result) { curl_multi_remove_handle(multi_, t.curl); const bool warm = t.warm; t.warm = false; std::vector entries; entries.swap(t.entries); std::erase_if(entries, [](const EntryPtr& e) { return e->cancelled.load(); }); long status = 0; curl_easy_getinfo(t.curl, CURLINFO_RESPONSE_CODE, &status); // What a blocking DNS gives: a name that does not resolve, a connection refused at once, or (a DNS // that points the vendor's domains at its own server) a TLS handshake for a certificate that is not // the host's, or nothing at all. The host switches to DoH, then to the second DoH server, and the // load retries. const std::string host = host_of(t.url); const bool blocked = result == CURLE_COULDNT_CONNECT || result == CURLE_COULDNT_RESOLVE_HOST || result == CURLE_SSL_CONNECT_ERROR || result == CURLE_PEER_FAILED_VERIFICATION || result == CURLE_GOT_NOTHING; // Only a failure on the host's current route moves it on: the loads sent before a switch fail // together, and one switch covers them all. A pinned address that stops answering is looked up // again on the same server first. const auto doh = doh_hosts_.find(host); const bool current = doh == doh_hosts_.end() ? t.doh == kResolver : t.pin ? t.pin == doh->second.pin : !doh->second.pin && t.doh == doh->second.server; const bool moved = blocked && current && (doh == doh_hosts_.end() || doh->second.pin || doh->second.server + 1 < kDohURLs.size()); if (moved) { Doh& route = doh_hosts_[host]; if (doh != doh_hosts_.end() && !route.pin) ++route.server; route.pin = nullptr; network::platform_log(("images: " + host + " unreachable (" + curl_easy_strerror(result) + "); resolving it over " + kDohURLs[route.server]).c_str()); } // Retried on the new route, whatever the error was on the old one. const bool rerouted = blocked && (moved || !current); if (result == CURLE_OK && current && doh != doh_hosts_.end() && !doh->second.pin) { // A redirect's address is its target's, not this host's. char* ip = nullptr; char* landed = nullptr; curl_easy_getinfo(t.curl, CURLINFO_EFFECTIVE_URL, &landed); if (landed && host_of(landed) == host && curl_easy_getinfo(t.curl, CURLINFO_PRIMARY_IP, &ip) == CURLE_OK && ip && *ip) { const bool tls = t.url.rfind("https:", 0) == 0; const std::string entry = host + (tls ? ":443:" : ":80:") + (std::strchr(ip, ':') ? "[" + std::string(ip) + "]" : std::string(ip)); doh_pins_.push_back(curl_slist_append(nullptr, entry.c_str())); doh->second.pin = doh_pins_.back(); network::platform_log(("images: " + host + " pinned to " + ip + " from DoH").c_str()); } } const bool transient = t.out_of_memory || rerouted || result == CURLE_COULDNT_CONNECT || result == CURLE_COULDNT_RESOLVE_HOST || result == CURLE_OPERATION_TIMEDOUT || result == CURLE_RECV_ERROR || result == CURLE_SEND_ERROR || result == CURLE_PARTIAL_FILE || status == 429 || status == 502 || status == 503 || status == 504; std::string reason; if (t.overflow) reason = "response exceeds " + std::to_string(kMaxEncodedBytes >> 20) + " MiB"; else if (t.out_of_memory) reason = "out of memory for the response"; else if (result != CURLE_OK) reason = curl_easy_strerror(result); else if (status != 200) reason = "HTTP " + std::to_string(status); if (result == CURLE_ABORTED_BY_CALLBACK) ++counters.aborted; // A warm response over its size limit is skipped, not failed. else count_request(t.url, warm && t.overflow ? std::string() : reason, std::chrono::duration_cast(Clock::now() - t.started).count(), t.body.size); if (reason.empty() && entries.empty()) { disk_.write(t.url, t.body); } else if (reason.empty()) { Body body = std::make_shared(std::move(t.body)); remember(t.url, body); disk_.write(t.url, *body); decode_later(std::move(entries), body); } else { std::lock_guard lock(mutex_); for (EntryPtr& entry : entries) { if (transient && ++entry->attempts < kAttempts) { ++counters.retries; entry->retry_at = Clock::now() + std::chrono::milliseconds(500 << (entry->attempts - 1)); fetches_.push_back(std::move(entry)); } else { ++counters.failed; entry->error = message(*entry, reason); finished_.push_back(std::move(entry)); } } } t.body.clear(); } void fetch_loop() { name_thread("img-fetch"); disk_.open(cache_directory_); unsigned active = 0, warming = 0; while (true) { std::vector starting; bool idle = false; const unsigned connections = network::downloading() ? kDownloadConnections : kConnections; auto due = Clock::now() + std::chrono::milliseconds(100); { std::lock_guard lock(mutex_); if (stopping_) break; const auto now = Clock::now(); std::erase_if(fetches_, [](const EntryPtr& e) { return e->cancelled.load(); }); std::stable_sort(fetches_.begin(), fetches_.end(), [](const EntryPtr& a, const EntryPtr& b) { return a->rank > b->rank; }); // Cached bytes start at once and take no connection; the rest wait out their dwell or retry // delay and a free connection. Loads joining a transfer still count one: the bound is loose. unsigned networked = 0; const Clock::time_point deferred{Clock::duration{deferred_until.load()}}; for (auto it = fetches_.begin(); it != fetches_.end() && encoded_bytes < kEncodedBudget;) { Entry& e = **it; const bool cached = held(e.url) || disk_.has(e.url); const auto wait = std::max(e.retry_at, cached ? Clock::time_point{} : std::max(e.dwell_until, deferred)); if (wait > now) { due = std::min(due, wait); ++it; continue; } if (!cached && active + networked >= connections) { ++it; continue; } networked += !cached; starting.push_back(std::move(*it)); it = fetches_.erase(it); } idle = fetches_.empty() && deferred <= now; if (fetches_.empty() && deferred > now) due = std::min(due, deferred); } fit_encoded(0); for (EntryPtr& entry : starting) start_fetch(std::move(entry), active); while (idle && active < connections && warming < kWarmConnections && encoded_bytes < kEncodedBudget) { const unsigned before = warming; start_warm(active, warming); if (warming == before) break; } int running = 0; curl_multi_perform(multi_, &running); int left = 0; while (CURLMsg* done = curl_multi_info_read(multi_, &left)) { if (done->msg != CURLMSG_DONE) continue; Transfer* t = nullptr; curl_easy_getinfo(done->easy_handle, CURLINFO_PRIVATE, &t); if (t->warm) --warming; complete(*t, done->data.result); --active; } const auto wait = std::chrono::duration_cast(due - Clock::now()).count(); curl_multi_poll(multi_, nullptr, 0, static_cast(std::clamp(wait, 1, 100)), nullptr); } for (Transfer& t : transfers_) if (busy(t)) { curl_multi_remove_handle(multi_, t.curl); t.entries.clear(); t.warm = false; } } void decode_loop() { name_thread("img-decode"); while (true) { EntryPtr entry; { std::unique_lock lock(mutex_); // The most wanted image waits for room in the budget rather than letting smaller ones pass it. auto next = decodes_.end(); wake_.wait(lock, [&] { std::erase_if(decodes_, [](const EntryPtr& e) { return e->cancelled.load(); }); next = std::max_element(decodes_.begin(), decodes_.end(), [](const EntryPtr& a, const EntryPtr& b) { return a->rank < b->rank; }); return stopping_ || (next != decodes_.end() && (!decoding_ || decoding_ + (*next)->decode_cost <= kDecodeBudget)); }); if (stopping_) break; entry = std::move(*next); decodes_.erase(next); decoding_ += entry->decode_cost; } const auto started = Clock::now(); const bool decoded = decode(*entry); counters.decode_us += std::chrono::duration_cast(Clock::now() - started).count(); // Out of heap: fetched again (usually from the encoded or disk cache) once memory may be free. const bool retry = !decoded && ++entry->attempts < kDecodeAttempts; if (retry) { entry->error.clear(); entry->retry_at = Clock::now() + std::chrono::seconds(entry->attempts); } { std::lock_guard lock(mutex_); decoding_ -= entry->decode_cost; if (retry) fetches_.push_back(std::move(entry)); else finished_.push_back(std::move(entry)); } wake_.notify_all(); if (retry) curl_multi_wakeup(multi_); } } std::mutex mutex_; std::condition_variable wake_; std::deque fetches_, decodes_; std::deque warms_; std::vector finished_; std::array transfers_{}; // Fetch thread only. DiskCache disk_; std::vector encoded_; std::uint64_t fetch_clock_ = 0; // Hosts the console's resolver sends nowhere: a DNS that blocks a vendor's domains (as consoles // running homebrew do, against system updates) also blocks its image CDN. These resolve over DoH, // through kDohURLs[server], until a load connects and pins its address. struct Doh { std::size_t server = 0; curl_slist* pin = nullptr; }; std::map doh_hosts_; // Every pin made, kept until stop(): a transfer in flight may still read a replaced one. std::vector doh_pins_; // Decode costs of the images being decoded, guarded by the mutex. std::size_t decoding_ = 0; CURLM* multi_ = nullptr; pthread_t fetch_thread_{}; std::array decode_threads_{}; unsigned decoders_ = 0; std::string cache_directory_; bool stopping_ = false, running_ = false, fetch_started_ = false, over_budget_ = false; // Render thread only. std::unordered_map by_key_; std::unordered_map by_id_; std::uint32_t next_id_ = 1; std::uint64_t clock_ = 0; std::size_t budget_ = kCacheBytes; }; Service service; } // namespace bool start(const std::string& cache_directory) { return service.start(cache_directory); } void stop(void (*evict)(std::uint32_t)) { service.stop(evict); } void discard(std::uint32_t id, const std::string& reason) { service.discard(id, reason); } std::uint32_t load(const std::string& url, int width, int height, Fit fit, bool prefetch, Result& result, std::string& error) { return service.load(url, width, height, fit, prefetch, result, error); } void release(std::uint32_t id, bool prefetch) { service.release(id, prefetch); } void warm(std::vector urls) { service.warm(std::move(urls)); } std::vector poll(void (*evict)(std::uint32_t)) { return service.poll(evict); } Memory memory() { return {encoded_bytes.load(), decoded_bytes.load()}; } void set_cache_bytes(std::size_t bytes) { cache_bytes = bytes; } void hold_memory(bool hold) { holds += hold ? 1 : -1; } void set_heap_probe(Heap (*probe)()) { heap_probe = probe; } void defer(int ms) { deferred_until = (Clock::now() + std::chrono::milliseconds(ms)).time_since_epoch().count(); } std::string stats() { char line[512]; const std::uint64_t decodes = counters.decodes; std::snprintf(line, sizeof line, "loads=%llu decoded-cache=%llu encoded-cache=%llu disk=%llu net=%llu decodes=%llu (%.1f ms avg) " "shown=%llu (%llu ms avg, %llu max) cancelled=%llu aborted=%llu retries=%llu failed=%llu", static_cast(counters.loads.load()), static_cast(counters.decoded_hits.load()), static_cast(counters.encoded_hits.load()), static_cast(counters.disk_hits.load()), static_cast(counters.fetches.load()), static_cast(decodes), decodes ? counters.decode_us / 1000.0 / decodes : 0.0, static_cast(counters.shown.load()), static_cast(counters.shown ? counters.shown_ms / counters.shown : 0), static_cast(counters.shown_max_ms.load()), static_cast(counters.cancelled.load()), static_cast(counters.aborted.load()), static_cast(counters.retries.load()), static_cast(counters.failed.load())); std::string out = line; std::vector busiest; { std::lock_guard lock(origins_mutex); busiest = origins; } std::sort(busiest.begin(), busiest.end(), [](const Origin& a, const Origin& b) { return a.requests > b.requests; }); if (busiest.size() > 4) busiest.resize(4); for (const Origin& o : busiest) { std::snprintf(line, sizeof line, " | %s %llu req %llu fail %llu ms avg %llu KiB avg", o.host.c_str(), static_cast(o.requests), static_cast(o.failures), static_cast(o.ms / o.requests), static_cast(o.bytes / o.requests >> 10)); out += line; if (o.failures) out += " (last: " + o.last + ")"; } return out; } } // namespace images #else namespace images { bool start(const std::string&) { return false; } void stop(void (*)(std::uint32_t)) {} void discard(std::uint32_t, const std::string&) {} std::uint32_t load(const std::string& url, int, int, Fit, bool, Result&, std::string& error) { error = "Image.load " + url + ": enable networking: true and filesystemAccess: console in app.json"; return 0; } void release(std::uint32_t, bool) {} void warm(std::vector) {} std::vector poll(void (*)(std::uint32_t)) { return {}; } Memory memory() { return {}; } void set_cache_bytes(std::size_t) {} void hold_memory(bool) {} void set_heap_probe(Heap (*)()) {} void defer(int) {} std::string stats() { return {}; } } // namespace images #endif