diff --git a/CLAUDE.md b/CLAUDE.md index 8661120..e7cd080 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,6 +128,35 @@ The entry point is `int main()` in `app/main.cpp`. - **Space Grotesk (the primary UI font) has no Cyrillic**: in Russian the app turns it off before `Application::init()` (`SwitchFontLoader::useBundledFont`, a local borealis patch) so the UI is the system font throughout. +- **Two players (and engines) can be alive at once**: borealis runs a + `popActivity` callback *before* deleting the popped activity, so the next + episode's `MpvView` is built before the previous one's destructor. Anything + process-wide a player touches must be counted or keyed, not toggled: the + keep-awake flag (`keepAwake`), the engine's cache file (a slot per live + engine in `torrentfs.c`), DHT targets (keyed by hash *and* owner). +- **Leave the player through `popPlayerThen`**: the settings panel is a + translucent activity, so the player keeps drawing — and reaching the end of + the file — under it. Counting pops from the top of the stack closed only the + panel. +- **A screen under another activity must not free its children**: the focus + stack holds a raw pointer to the view that opened the screen above. Late + results wait until `ui::isOnTop(this)` (Home's Continue row, Stremio + catalogs, the sources list). A rebuilt row gets a fresh `alive` token so + artwork landing for an old card is dropped. +- **Long work goes to `workers::run`, not `brls::async`**: borealis has one + task thread, and the library/history saves queue on it. An engine open, a + magnet resolve, the update download all run on the pool and take a cancel + flag; `torrent_meta_abort_all()` and `http::abortAll()` end them as the app + quits (`getExitEvent`), and `library::flush()` writes the save borealis + drops from its queue (`getExitDoneEvent`). +- **Saved files go through `fsutil::writeAtomic` / `readFirst`** + (`app/fsutil.hpp`): .tmp + rename with every write checked, the previous + version kept as .bak, and a reader that falls back to them when a file is + missing or cut short. Writing a data file in place lost it to a power cut. +- `http::get` succeeds on any HTTP answer: pass `status` (or use the Stremio + `getJson`) and treat non-2xx as a failure before caching anything. +- `http::get`/`postJson` reuse one curl handle per thread (kept connections); + `http::shutdown()` closes them in `main()` once every thread is joined. ## Debugging on device diff --git a/app/browse.cpp b/app/browse.cpp index e6d9c0d..eeda38c 100644 --- a/app/browse.cpp +++ b/app/browse.cpp @@ -1202,8 +1202,17 @@ void bindLibraryToggle(brls::View* host, const std::string& authKey, // The footer follows immediately -- inLibrary has already flipped, // the write goes on in the background -- and only moves back if the // account refuses it. + // The answer comes after two round trips: B may have closed the + // screen by then. `v` is touched only while it is still the + // focus or one of its ancestors -- the focused chain is alive. auto repaint = [v, hint, id = item.id]() { - v->updateActionHint(brls::BUTTON_X, hint(id)); + for (brls::View* f = brls::Application::getCurrentFocus(); f; + f = f->getParent()) + if (f == v) + { + v->updateActionHint(brls::BUTTON_X, hint(id)); + break; + } brls::Application::getGlobalHintsUpdateEvent()->fire(); }; stremio::setLibraryMemberAsync( diff --git a/app/config.cpp b/app/config.cpp index 9bbfd23..62d38e5 100644 --- a/app/config.cpp +++ b/app/config.cpp @@ -11,6 +11,7 @@ #include #include "appdata.hpp" +#include "fsutil.hpp" #include "i18n.hpp" namespace config @@ -60,9 +61,62 @@ std::string readStr(const std::string& body, const char* key, if (k == std::string::npos) return dflt; size_t q1 = body.find('"', body.find(':', k + pat.size())); if (q1 == std::string::npos) return dflt; - size_t q2 = body.find('"', q1 + 1); - if (q2 == std::string::npos) return dflt; - return body.substr(q1 + 1, q2 - q1 - 1); + // What jsonStr() escapes: \" \\ and \u00XX. Anything else stays as + // written, so a value saved before escaping existed reads back the same. + std::string out; + for (size_t i = q1 + 1; i < body.size(); i++) + { + char c = body[i]; + if (c == '"') return out; + if (c == '\\' && i + 1 < body.size()) + { + char e = body[i + 1]; + if (e == '"' || e == '\\') + { + out += e; + i++; + continue; + } + if (e == 'u' && i + 5 < body.size() && body.compare(i + 2, 2, "00") == 0) + { + char* end = nullptr; + std::string hex = body.substr(i + 4, 2); + long v = std::strtol(hex.c_str(), &end, 16); + if (end && *end == 0) + { + out += (char)v; + i += 5; + continue; + } + } + } + out += c; + } + return dflt; // no closing quote +} + +// `s` as a JSON string: quotes, backslashes and control characters escaped +// (a value with a quote in it used to break the whole file). +std::string jsonStr(const std::string& s) +{ + std::string o = "\""; + for (unsigned char c : s) + { + if (c == '"' || c == '\\') + { + o += '\\'; + o += (char)c; + } + else if (c < 0x20) + { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", c); + o += buf; + } + else + o += (char)c; + } + return o + "\""; } // The languages offered, and what each maps to for mpv. Both 639-2 spellings @@ -280,20 +334,16 @@ const std::vector& uiWidthLabels() void load() { - FILE* f = std::fopen(kPath, "rb"); - if (!f) + // config.json, or after a cut save its .tmp / the version before. + std::string body; + if (!fsutil::readFirst(kPath, body, + [](const std::string& b) { return fsutil::looksComplete(b); })) { brls::Logger::info("[config] no config.json, using defaults"); defaultTmdbProxy(); return; } - std::string body; - char buf[512]; - size_t n; - while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) body.append(buf, n); - std::fclose(f); - std::string sTab = readStr(body, "startupTab", "home"); cfg.startupTab = (sTab == "stremio") ? Tab::STREMIO : (sTab == "jackett") ? Tab::JACKETT : @@ -374,82 +424,64 @@ void load() bool save() { - FILE* f = std::fopen(kPath, "wb"); - if (!f) - { - brls::Logger::warning("[config] cannot write {}", kPath); - return false; - } const char* sTabStr = (cfg.startupTab == Tab::STREMIO) ? "stremio" : (cfg.startupTab == Tab::JACKETT) ? "jackett" : (cfg.startupTab == Tab::HISTORY) ? "history" : (cfg.startupTab == Tab::HOME) ? "home" : (cfg.startupTab == Tab::BOOKMARKS) ? "bookmarks" : "local"; + std::string o = "{\n"; + auto kv = [&o](const char* key, const std::string& value) { + o += " \""; + o += key; + o += "\": "; + o += value; + o += ",\n"; + }; + auto b = [](bool v) { return std::string(v ? "true" : "false"); }; + auto n = [](int v) { return std::to_string(v); }; + kv("configVersion", "2"); + kv("bufferMode", n(cfg.bufferMode)); + kv("startupTab", jsonStr(sTabStr)); + kv("jackettUrl", jsonStr(cfg.jackettUrl)); + kv("jackettApiKey", jsonStr(cfg.jackettApiKey)); + kv("jackettSetupAsked", b(cfg.jackettSetupAsked)); + kv("torrServerUrl", jsonStr(cfg.torrServerUrl)); + kv("proxyUrl", jsonStr(cfg.proxyUrl)); + kv("tmdbApiKey", jsonStr(cfg.tmdbApiKey)); + kv("tmdbApiUrl", jsonStr(cfg.tmdbApiUrl)); + kv("tmdbImageUrl", jsonStr(cfg.tmdbImageUrl)); + kv("tmdbProxyUrl", jsonStr(cfg.tmdbProxyUrl)); // Only a choice the user made is kept: otherwise the proxy keeps following // the UI language. - const std::string proxyLine = - cfg.tmdbProxyUserSet - ? std::string(" \"tmdbProxyEnabled\": ") + - (cfg.tmdbProxyEnabled ? "true" : "false") + ",\n" - : std::string(); - std::fprintf(f, - "{\n" - " \"configVersion\": 2,\n" - " \"bufferMode\": %d,\n" - " \"startupTab\": \"%s\",\n" - " \"jackettUrl\": \"%s\",\n" - " \"jackettApiKey\": \"%s\",\n" - " \"jackettSetupAsked\": %s,\n" - " \"torrServerUrl\": \"%s\",\n" - " \"proxyUrl\": \"%s\",\n" - " \"tmdbApiKey\": \"%s\",\n" - " \"tmdbApiUrl\": \"%s\",\n" - " \"tmdbImageUrl\": \"%s\",\n" - " \"tmdbProxyUrl\": \"%s\",\n" - "%s" - " \"logging\": %s,\n" - " \"hide4k\": %s,\n" - " \"rateGovernor\": %s,\n" - " \"ramStream\": %s,\n" - " \"checkUpdates\": %s,\n" - " \"audioLang\": \"%s\",\n" - " \"subLang\": \"%s\",\n" - " \"subtitles\": %s,\n" - " \"hwDecode\": %s,\n" - " \"audioBoost\": %s,\n" - " \"picBrightness\": %d,\n" - " \"picContrast\": %d,\n" - " \"picGamma\": %d,\n" - " \"picSaturation\": %d,\n" - " \"dockedUiWidth\": %d,\n" - " \"handheldUiWidth\": %d,\n" - " \"language\": \"%s\",\n" - " \"accent\": \"%s\",\n" - " \"themeVariant\": \"%s\",\n" - " \"listStyle\": \"%s\"\n" - "}\n", - cfg.bufferMode, sTabStr, - cfg.jackettUrl.c_str(), - cfg.jackettApiKey.c_str(), - cfg.jackettSetupAsked ? "true" : "false", - cfg.torrServerUrl.c_str(), - cfg.proxyUrl.c_str(), - cfg.tmdbApiKey.c_str(), - cfg.tmdbApiUrl.c_str(), - cfg.tmdbImageUrl.c_str(), - cfg.tmdbProxyUrl.c_str(), - proxyLine.c_str(), - cfg.logging ? "true" : "false", cfg.hide4k ? "true" : "false", - cfg.rateGovernor ? "true" : "false", - cfg.ramStream ? "true" : "false", - cfg.checkUpdates ? "true" : "false", cfg.audioLang.c_str(), - cfg.subLang.c_str(), cfg.subtitles ? "true" : "false", - cfg.hwDecode ? "true" : "false", - cfg.audioBoost ? "true" : "false", cfg.picBrightness, - cfg.picContrast, cfg.picGamma, cfg.picSaturation, cfg.dockedUiWidth, - cfg.handheldUiWidth, cfg.language.c_str(), cfg.accent.c_str(), - cfg.themeVariant.c_str(), cfg.listStyle.c_str()); - std::fclose(f); + if (cfg.tmdbProxyUserSet) kv("tmdbProxyEnabled", b(cfg.tmdbProxyEnabled)); + kv("logging", b(cfg.logging)); + kv("hide4k", b(cfg.hide4k)); + kv("rateGovernor", b(cfg.rateGovernor)); + kv("ramStream", b(cfg.ramStream)); + kv("checkUpdates", b(cfg.checkUpdates)); + kv("audioLang", jsonStr(cfg.audioLang)); + kv("subLang", jsonStr(cfg.subLang)); + kv("subtitles", b(cfg.subtitles)); + kv("hwDecode", b(cfg.hwDecode)); + kv("audioBoost", b(cfg.audioBoost)); + kv("picBrightness", n(cfg.picBrightness)); + kv("picContrast", n(cfg.picContrast)); + kv("picGamma", n(cfg.picGamma)); + kv("picSaturation", n(cfg.picSaturation)); + kv("dockedUiWidth", n(cfg.dockedUiWidth)); + kv("handheldUiWidth", n(cfg.handheldUiWidth)); + kv("language", jsonStr(cfg.language)); + kv("accent", jsonStr(cfg.accent)); + kv("themeVariant", jsonStr(cfg.themeVariant)); + o += " \"listStyle\": " + jsonStr(cfg.listStyle) + "\n}\n"; + + // Atomic: rewritten in place, a power cut mid-save left an empty file -- + // the Jackett address and key, the TMDB key and every setting gone. + if (!fsutil::writeAtomic(kPath, o)) + { + brls::Logger::warning("[config] cannot write {}", kPath); + return false; + } return true; } diff --git a/app/fsutil.hpp b/app/fsutil.hpp new file mode 100644 index 0000000..e87516c --- /dev/null +++ b/app/fsutil.hpp @@ -0,0 +1,84 @@ +#pragma once + +// Small file helpers for the app's own data (config, library, history, +// lists). Header-only: the PC test builds (pctest/run_tests.sh) compile a +// few app files on their own and need nothing extra to link. + +#include +#include +#include + +namespace fsutil +{ + +// The whole file into `out`. False if it cannot be opened. +inline bool readFile(const std::string& path, std::string& out) +{ + FILE* f = std::fopen(path.c_str(), "rb"); + if (!f) return false; + char buf[8192]; + size_t n; + out.clear(); + while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) out.append(buf, n); + std::fclose(f); + return true; +} + +// Replaces `path` with `data` so that a power cut or a full SD card never +// leaves a truncated file behind: written to path.tmp and checked (fwrite, +// fflush, fclose), then renamed in. The previous version stays as path.bak. +// False on any failure, with the old file still in place. +inline bool writeAtomic(const std::string& path, const std::string& data) +{ + std::string tmp = path + ".tmp"; + FILE* f = std::fopen(tmp.c_str(), "wb"); + if (!f) return false; + bool ok = std::fwrite(data.data(), 1, data.size(), f) == data.size(); + ok = std::fflush(f) == 0 && ok; + ok = std::fclose(f) == 0 && ok; + if (!ok) + { + std::remove(tmp.c_str()); + return false; + } + // FAT cannot rename over an existing file: move the old one aside first. + // A cut between the two renames leaves path missing and path.tmp whole, + // which readFirst() then picks up. + std::string bak = path + ".bak"; + std::remove(bak.c_str()); + bool hadOld = std::rename(path.c_str(), bak.c_str()) == 0; + if (std::rename(tmp.c_str(), path.c_str()) != 0) + { + if (hadOld) std::rename(bak.c_str(), path.c_str()); + return false; + } + return true; +} + +// The first of path, path.tmp (a save cut between its renames) and path.bak +// (the version before the last save) that opens and passes `valid` (when +// given). False if none does. +inline bool readFirst(const std::string& path, std::string& out, + const std::function& valid = nullptr) +{ + const std::string candidates[] = { path, path + ".tmp", path + ".bak" }; + for (const auto& p : candidates) + if (readFile(p, out) && (!valid || valid(out))) return true; + out.clear(); + return false; +} + +// A JSON document that was written to the end: its last non-space character +// closes it. A file cut short by a lost write fails this. +inline bool looksComplete(const std::string& body, char last = '}') +{ + for (size_t i = body.size(); i > 0; i--) + { + char c = body[i - 1]; + if (c == ' ' || c == '\n' || c == '\r' || c == '\t') continue; + return c == last; + } + return false; +} + +} // namespace fsutil diff --git a/app/history.cpp b/app/history.cpp index 1d3afb0..fd13a09 100644 --- a/app/history.cpp +++ b/app/history.cpp @@ -9,6 +9,7 @@ #include #include "appdata.hpp" +#include "fsutil.hpp" #include "json.hpp" namespace history @@ -53,28 +54,13 @@ void load() g_items.clear(); g_loaded = true; - // save() removes the old file before renaming the new one into place (FAT - // will not rename over an existing file); a crash between the two leaves - // only the .tmp, which is complete -- read that rather than lose everything. - std::string tmp = std::string(APPDATA_HISTORY) + ".tmp"; - FILE* f = std::fopen(APPDATA_HISTORY, "rb"); - if (!f) f = std::fopen(tmp.c_str(), "rb"); - if (!f) return; - - std::fseek(f, 0, SEEK_END); - long sz = std::ftell(f); - std::fseek(f, 0, SEEK_SET); - if (sz <= 0) - { - std::fclose(f); - return; - } - + // The saved file, or (cut short, or missing after a save cut between its + // renames) the .tmp or the version before -- see fsutil::writeAtomic. std::string body; - body.resize(sz); - size_t rd = std::fread(&body[0], 1, sz, f); - std::fclose(f); - body.resize(rd); + if (!fsutil::readFirst(APPDATA_HISTORY, body, [](const std::string& b) { + return fsutil::looksComplete(b); + })) + return; std::vector objs = json::objects(body, "history"); g_items.reserve(objs.size()); @@ -151,19 +137,10 @@ void save() } out += " ]\n}\n"; - std::string tmp = std::string(APPDATA_HISTORY) + ".tmp"; - FILE* f = std::fopen(tmp.c_str(), "wb"); - if (!f) - { - brls::Logger::error("[history] failed to open {} for writing", tmp); - return; - } - std::fwrite(out.data(), 1, out.size(), f); - std::fflush(f); - std::fclose(f); - - std::remove(APPDATA_HISTORY); - std::rename(tmp.c_str(), APPDATA_HISTORY); + // Checked and atomic: an unchecked write on a full card used to replace + // the good file with a truncated one. + if (!fsutil::writeAtomic(APPDATA_HISTORY, out)) + brls::Logger::error("[history] could not write {}", APPDATA_HISTORY); } std::vector all() diff --git a/app/home.cpp b/app/home.cpp index b93910b..d1f79f1 100644 --- a/app/home.cpp +++ b/app/home.cpp @@ -149,15 +149,21 @@ class HomeRoot : public brls::Box build(); } - ~HomeRoot() override { *alive = false; } + ~HomeRoot() override + { + *alive = false; + *continueAlive = false; + } // The library and the history change behind this tab (a film watched, an // episode finished): keep Continue current. Polled, as nothing signals it. void draw(NVGcontext* vg, float x, float y, float w, float h, brls::Style style, brls::FrameContext* ctx) override { + // Only while on top: under a dialog or the player, a Continue card may + // be the one the focus goes back to when that closes (see ui::isOnTop). uint32_t gen = library::generation() * 7919u + history::generation(); - if (gen != seenGen) + if (gen != seenGen && (!continueRow || ui::isOnTop(this))) { seenGen = gen; if (continueRow) fillContinue(); @@ -167,6 +173,9 @@ class HomeRoot : public brls::Box private: std::shared_ptr alive = std::make_shared(true); + // The Continue row's cards only: rebuilt (freed) while the screen lives, + // so artwork landing for an old card must not reach it. + std::shared_ptr continueAlive = std::make_shared(true); View view; brls::Box* list = nullptr; brls::Box* hero = nullptr; @@ -406,15 +415,30 @@ class HomeRoot : public brls::Box } // Moves the focus off the Continue row before it is rebuilt (a freed - // focused view crashes borealis): the hero, else the first poster row. - void parkFocus() + // focused view crashes borealis): the hero, else the first poster row, + // else -- TMDB down or still loading, so neither exists -- this screen + // itself with the highlight hidden. True in that last case: the caller + // hands the focus on once the row is rebuilt. + bool parkFocus() { - if (!focusInside(continueRow)) return; + if (!focusInside(continueRow)) return false; if (heroWatch) + { brls::Application::giveFocus(heroWatch); - else if (firstRow && !firstRow->getChildren().empty() && - firstRow->getChildren()[0]->isFocusable()) + return false; + } + if (firstRow && !firstRow->getChildren().empty() && + firstRow->getChildren()[0]->isFocusable()) + { brls::Application::giveFocus(firstRow->getChildren()[0]); + return false; + } + // giveFocus() on a non-focusable view is a no-op, which would leave + // the focus on the card about to be freed. + this->setFocusable(true); + this->setHideHighlight(true); + brls::Application::giveFocus(this); + return true; } // The library's titles first (one press back into the film, or on to the @@ -422,7 +446,21 @@ class HomeRoot : public brls::Box // (a local .torrent, a magnet). void fillContinue() { - parkFocus(); + bool parkedHere = parkFocus(); + fillContinueRow(); + if (parkedHere && !continueRow->getChildren().empty()) + { + brls::Application::giveFocus(continueRow->getChildren()[0]); + this->setFocusable(false); + } + // Otherwise nothing is left to focus: it stays parked here (B still + // goes back to the tab bar). + } + + void fillContinueRow() + { + *continueAlive = false; + continueAlive = std::make_shared(true); continueRow->clearViews(); std::set libSources; @@ -443,7 +481,8 @@ class HomeRoot : public brls::Box auto* card = ui::wideCard(title, it.key() + "_bdw", art, "", next.kind == library::Next::Kind::Resume ? next.progress : -1.0, - alive, [copy]() { continueContent(copy); }, kWideW); + continueAlive, [copy]() { continueContent(copy); }, + kWideW); std::string key = e.key(); card->registerAction(tr("Remove"), brls::BUTTON_X, [key](brls::View*) { library::dismissContinue(key); @@ -462,7 +501,7 @@ class HomeRoot : public brls::Box continue; double prog = it.durationSec > 0 ? it.positionSec / it.durationSec : -1.0; history::Item copy = it; - auto* card = ui::wideCard(it.title, "", "", it.posterPath, prog, alive, + auto* card = ui::wideCard(it.title, "", "", it.posterPath, prog, continueAlive, [copy]() { PlayerArt art; art.posterPath = copy.posterPath; diff --git a/app/http.cpp b/app/http.cpp index 5a22296..1eee63d 100644 --- a/app/http.cpp +++ b/app/http.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include // strncasecmp #include @@ -18,10 +19,50 @@ namespace http { +// Answers held in memory (JSON, an image) are capped: an addon or a proxy +// streaming an endless body would otherwise take the console's RAM with it. +// Returning short makes curl fail the transfer (CURLE_WRITE_ERROR). +static constexpr size_t kMaxBody = 32u * 1024 * 1024; + static size_t writeCb(char* ptr, size_t size, size_t nmemb, void* userdata) { - ((std::string*)userdata)->append(ptr, size * nmemb); - return size * nmemb; + auto* s = (std::string*)userdata; + size_t n = size * nmemb; + if (s->size() + n > kMaxBody) return 0; + s->append(ptr, n); + return n; +} + +// One easy handle per thread, reset and reused for get() and postJson(): curl +// keeps its open connections and TLS sessions on the handle, so the posters of +// a screen (one host) share a connection instead of paying a TLS handshake -- +// and a parse of the CA bundle -- each, which on the console is the slow part. +// Every handle made is listed, for shutdown(). +static std::mutex g_handlesLock; +static std::vector g_handles; +static thread_local CURL* t_handle = nullptr; + +static CURL* threadHandle() +{ + if (t_handle) + { + curl_easy_reset(t_handle); // options only: the caches stay + return t_handle; + } + t_handle = curl_easy_init(); + if (t_handle) + { + std::lock_guard g(g_handlesLock); + g_handles.push_back(t_handle); + } + return t_handle; +} + +void shutdown() +{ + std::lock_guard g(g_handlesLock); + for (CURL* h : g_handles) curl_easy_cleanup(h); + g_handles.clear(); } // Set by abortAll() as the app quits: every transfer in flight stops at its @@ -41,6 +82,11 @@ static void stoppable(CURL* curl) { curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, stopCb); + // Every request goes through here, so the common limits do too: curl's + // own connect timeout is 300 s, and without NOSIGNAL a resolver timeout + // would use signals, which the worker threads cannot take. + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); } // Copied from config by applyProxy(): requests run on worker threads, which @@ -123,7 +169,7 @@ static std::string tlsAwareErr(CURLcode rc, const char* errbuf) bool postJson(const char* url, const std::string& body, std::string& resp, std::string& err) { - CURL* curl = curl_easy_init(); + CURL* curl = threadHandle(); // reused: see threadHandle if (!curl) { err = "cannot initialise the network"; @@ -149,7 +195,12 @@ bool postJson(const char* url, const std::string& body, std::string& resp, CURLcode rc = curl_easy_perform(curl); curl_slist_free_all(hdrs); - curl_easy_cleanup(curl); + // The handle stays with this thread (threadHandle): leave it nothing + // that dies with this call (curl may report into the error buffer while + // it closes an old connection later on). + curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, nullptr); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, nullptr); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, nullptr); if (rc != CURLE_OK) { @@ -167,7 +218,7 @@ bool get(const std::string& url, std::string& resp, std::string& err, const char* accept, long timeoutSec, long* status) { if (status) *status = 0; - CURL* curl = curl_easy_init(); + CURL* curl = threadHandle(); // reused: see threadHandle if (!curl) { err = "cannot initialise the network"; @@ -193,7 +244,12 @@ bool get(const std::string& url, std::string& resp, std::string& err, CURLcode rc = curl_easy_perform(curl); if (status) curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, status); if (hdrs) curl_slist_free_all(hdrs); - curl_easy_cleanup(curl); + // The handle stays with this thread (threadHandle): leave it nothing + // that dies with this call (curl may report into the error buffer while + // it closes an old connection later on). + curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, nullptr); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, nullptr); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, nullptr); if (rc != CURLE_OK) { err = tlsAwareErr(rc, errbuf); @@ -265,6 +321,7 @@ bool download(const std::string& url, const std::string& path, std::string& err, // MB over hotel wifi. LOW_SPEED_* kills it when it actually stalls instead. curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1024L); curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 30L); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L); // curl's default is 300 s curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); // GitHub redirects to a CDN verifyTls(curl, url); // this one lands an executable nro on the SD card stoppable(curl); @@ -276,7 +333,9 @@ bool download(const std::string& url, const std::string& path, std::string& err, long code = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code); curl_easy_cleanup(curl); - std::fclose(ctx.f); + // fclose flushes stdio's buffer: on a full SD card that is where the + // write fails, after every fwrite has "succeeded". + bool closed = std::fclose(ctx.f) == 0; if (rc != CURLE_OK) { @@ -284,6 +343,12 @@ bool download(const std::string& url, const std::string& path, std::string& err, err = ctx.aborted ? "cancelled" : tlsAwareErr(rc, errbuf); return false; } + if (!closed) + { + std::remove(path.c_str()); + err = "cannot write " + path; + return false; + } // A 404 is a perfectly successful transfer of an error page, and would land // on disk as a "download" -- check what we were actually served. if (code >= 400) @@ -318,8 +383,12 @@ bool downloadTorrent(const std::string& url, const std::string& path, { magnet.clear(); + // Into .part, renamed over `path` only when complete: the cached .torrent + // a history entry or Continue points at must survive a failed refetch + // (Jackett offline, a magnet redirect, a cut transfer). + const std::string part = path + ".part"; DlCtx ctx; - ctx.f = std::fopen(path.c_str(), "wb"); + ctx.f = std::fopen(part.c_str(), "wb"); if (!ctx.f) { err = "cannot write " + path; @@ -330,7 +399,7 @@ bool downloadTorrent(const std::string& url, const std::string& path, if (!curl) { std::fclose(ctx.f); - std::remove(path.c_str()); + std::remove(part.c_str()); err = "cannot initialise the network"; return false; } @@ -345,6 +414,7 @@ bool downloadTorrent(const std::string& url, const std::string& path, curl_easy_setopt(curl, CURLOPT_HEADERDATA, &location); curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, errbuf); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); + curl_easy_setopt(curl, CURLOPT_MAXFILESIZE, (long)(64 * 1024 * 1024)); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 8L); // Only plain web redirects are followed; a magnet: one stops the transfer @@ -357,26 +427,33 @@ bool downloadTorrent(const std::string& url, const std::string& path, long code = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code); curl_easy_cleanup(curl); - std::fclose(ctx.f); + bool closed = std::fclose(ctx.f) == 0; if (location.compare(0, 7, "magnet:") == 0 && (rc != CURLE_OK || (code >= 300 && code < 400))) { - std::remove(path.c_str()); + std::remove(part.c_str()); magnet = location; return true; } if (rc != CURLE_OK) { - std::remove(path.c_str()); + std::remove(part.c_str()); err = tlsAwareErr(rc, errbuf); return false; } if (code >= 400) { - std::remove(path.c_str()); + std::remove(part.c_str()); err = "HTTP " + std::to_string(code); return false; } + std::remove(path.c_str()); // FAT cannot rename over a file + if (!closed || std::rename(part.c_str(), path.c_str()) != 0) + { + std::remove(part.c_str()); + err = "cannot write " + path; + return false; + } return true; } diff --git a/app/http.hpp b/app/http.hpp index e329a8e..433f541 100644 --- a/app/http.hpp +++ b/app/http.hpp @@ -18,6 +18,11 @@ namespace http // Stops every transfer in flight, and any started after, as the app quits. void abortAll(); +// Closes the connections get() and postJson() keep open. Once, as the app +// quits, after every thread that made requests has been joined: before +// main() returns, which is when the console tears its sockets down. +void shutdown(); + // GET `url` into `resp`. False on failure, with a readable reason in `err`. // `accept` is an optional full header line ("Accept: image/jpeg"). // `timeoutSec` bounds the whole request (TorrServer answers only once it has diff --git a/app/i18n_ru.cpp b/app/i18n_ru.cpp index 8912365..3606e21 100644 --- a/app/i18n_ru.cpp +++ b/app/i18n_ru.cpp @@ -222,6 +222,9 @@ const Pair kRu[] = { { "Normal", "Обычная" }, { "Torrent", "Торрент" }, { "Connecting...", "Подключение..." }, + { "Connection lost, reconnecting...", "Связь потеряна, переподключение..." }, + { "Playback stopped: the connection was lost", + "Воспроизведение остановлено: связь потеряна" }, { "Player initialisation failed", "Не удалось запустить плеер" }, { "Waiting for TorrServer...", "Ожидание TorrServer..." }, { "Failed: ", "Ошибка: " }, diff --git a/app/json.cpp b/app/json.cpp index af97bb8..8e5d5f6 100644 --- a/app/json.cpp +++ b/app/json.cpp @@ -1,5 +1,6 @@ #include "json.hpp" +#include #include #include @@ -95,6 +96,10 @@ std::string str(const std::string& body, const char* key, size_t from) i += 6; } } + // A half of a pair left alone (or \u0000) is not text: + // as UTF-8 it is invalid bytes, and a NUL cuts the + // string short in every C API it reaches. + if ((cp >= 0xD800 && cp <= 0xDFFF) || cp == 0) cp = 0xFFFD; // Encode as UTF-8; the UI's text stack expects it. if (cp < 0x80) @@ -165,8 +170,31 @@ std::string escape(const std::string& s) std::string out; for (char c : s) { - if (c == '"' || c == '\\') out += '\\'; - out += c; + unsigned char u = (unsigned char)c; + if (c == '"' || c == '\\') + { + out += '\\'; + out += c; + } + else if (u < 0x20) + { + // Control characters must be escaped: a raw one (a tab in a + // title) made the whole document invalid, and the server + // rejected it. + static const char* hexd = "0123456789abcdef"; + switch (c) + { + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + out += "\\u00"; + out += hexd[u >> 4]; + out += hexd[u & 0xF]; + } + } + else + out += c; } return out; } @@ -194,7 +222,14 @@ long long integer(const std::string& body, const char* key, long long dflt) if (v >= body.size() || body[v] < '0' || body[v] > '9') return dflt; long long n = 0; while (v < body.size() && body[v] >= '0' && body[v] <= '9') - n = n * 10 + (body[v++] - '0'); + { + int d = body[v++] - '0'; + // Saturate instead of overflowing (undefined behaviour). + if (n > (LLONG_MAX - d) / 10) + n = LLONG_MAX; + else + n = n * 10 + d; + } return neg ? -n : n; } from = k + 1; diff --git a/app/library.cpp b/app/library.cpp index bcbe4e6..709e184 100644 --- a/app/library.cpp +++ b/app/library.cpp @@ -8,6 +8,8 @@ #include "json.hpp" +#include "fsutil.hpp" + #ifndef LIBRARY_NO_UI #include #include "appdata.hpp" @@ -289,46 +291,39 @@ uint32_t g_gen = 0; int64_t now() { return (int64_t)std::time(nullptr); } -bool readFile(const std::string& path, std::string& out) -{ - FILE* f = std::fopen(path.c_str(), "rb"); - if (!f) return false; - char buf[8192]; - size_t n; - out.clear(); - while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) out.append(buf, n); - std::fclose(f); - return true; -} +using fsutil::readFile; bool writeFile(const std::string& path, const std::string& data) { - std::string tmp = path + ".tmp"; - FILE* f = std::fopen(tmp.c_str(), "wb"); - if (!f) return false; - bool ok = std::fwrite(data.data(), 1, data.size(), f) == data.size(); - ok = std::fclose(f) == 0 && ok; - if (!ok) return false; - std::remove(path.c_str()); - return std::rename(tmp.c_str(), path.c_str()) == 0; + return fsutil::writeAtomic(path, data); } // Off the UI thread: the player records every 15 s, and an SD write in the // frame is a stutter. Snapshot under the lock, write without it. +std::mutex g_writeLock; // two saves never interleave on disk +uint32_t g_writtenGen = 0; // the generation on disk (under g_writeLock) + +// Writes `data` (generation `gen`) unless something newer is already there. +void writeGen(const std::string& data, uint32_t gen) +{ + std::lock_guard g(g_writeLock); + if ((int32_t)(gen - g_writtenGen) <= 0) return; + if (writeFile(APPDATA_LIBRARY, data)) + g_writtenGen = gen; + else + brls::Logger::warning("[library] could not write {}", APPDATA_LIBRARY); +} + void saveAsync() { std::string data; + uint32_t gen; { std::lock_guard g(g_lock); data = toJson(g_entries); - g_gen++; + gen = ++g_gen; } - brls::async([data]() { - static std::mutex writeLock; // two saves never interleave on disk - std::lock_guard g(writeLock); - if (!writeFile(APPDATA_LIBRARY, data)) - brls::Logger::warning("[library] could not write {}", APPDATA_LIBRARY); - }); + brls::async([data, gen]() { writeGen(data, gen); }); } Entry& ensure(const content::Item& item) @@ -355,13 +350,33 @@ Entry& ensure(const content::Item& item) void load() { std::string body; - bool ok = readFile(APPDATA_LIBRARY, body) || - readFile(std::string(APPDATA_LIBRARY) + ".tmp", body); // a cut-off save + // A file cut short (a write FAT lost) is passed over for the .tmp of a + // save cut between its renames, or the version before the last save -- + // otherwise the partial list would be the next save's whole library. + bool ok = fsutil::readFirst(APPDATA_LIBRARY, body, [](const std::string& b) { + return fsutil::looksComplete(b); + }); std::lock_guard g(g_lock); g_entries = ok ? fromJson(body) : std::vector(); brls::Logger::info("[library] {} titles", g_entries.size()); } +void flush() +{ + std::string data; + uint32_t gen; + { + std::lock_guard g(g_lock); + gen = g_gen; + { + std::lock_guard w(g_writeLock); + if (gen == g_writtenGen) return; // nothing pending + } + data = toJson(g_entries); + } + writeGen(data, gen); +} + uint32_t generation() { std::lock_guard g(g_lock); diff --git a/app/library.hpp b/app/library.hpp index 6691f82..554d08b 100644 --- a/app/library.hpp +++ b/app/library.hpp @@ -99,6 +99,9 @@ int merge(std::vector& into, const std::vector& incoming); void load(); uint32_t generation(); // bumps on every change, for screens to refresh +// Writes now, on this thread, whatever a background save has not written yet. +// For app exit: borealis drops brls::async tasks still queued when it stops. +void flush(); bool find(const std::string& key, Entry& out); std::vector all(); diff --git a/app/main.cpp b/app/main.cpp index 25760ac..88c54d2 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -16,9 +16,11 @@ #include #include #include +#include #include #include +#include // TextureCache #include #include #ifdef __SWITCH__ @@ -56,6 +58,7 @@ #include "autotest.hpp" #include "widgets.hpp" // ui::paintField #include "workers.hpp" // workers::stop +#include "fsutil.hpp" #include "history.hpp" #include "history_tab.hpp" #include "bookmarks.hpp" @@ -73,6 +76,8 @@ extern "C" { namespace { +// Textures borealis may keep after no view uses them (see main()). +constexpr size_t kUnusedTextures = 48; #ifdef __SWITCH__ // --- Async log sink --------------------------------------------------------- @@ -411,18 +416,35 @@ std::string magnetHash(const std::string& magnet) // scanMagnets to label a row with the size it recorded. int64_t magnetCachedTotal(const std::string& hash); +// The whole magnet file-list cache (APPDATA_MAGNET_FILES), read once and kept +// until the file changes: the Local tab asked for it once per magnet row, and +// read it whole every time. Any thread (the name resolves write it). +bool readMagnetFiles(std::string& body) +{ + static std::mutex lock; + static std::string cached; + static off_t cachedSize = -1; + static time_t cachedMtime = 0; + struct stat st; + if (stat(APPDATA_MAGNET_FILES, &st) != 0) return false; + std::lock_guard g(lock); + if (st.st_size != cachedSize || st.st_mtime != cachedMtime) + { + if (!fsutil::readFile(APPDATA_MAGNET_FILES, cached)) return false; + cachedSize = st.st_size; + cachedMtime = st.st_mtime; + } + body = cached; + return true; +} + // Whether a magnet's file list has been resolved and cached (APPDATA_MAGNET_FILES // holds a line for its hash, even if that line is the "no video" marker). bool magnetFilesCached(const std::string& hash) { if (hash.empty()) return false; - FILE* f = std::fopen(APPDATA_MAGNET_FILES, "rb"); - if (!f) return false; std::string body; - char buf[512]; - size_t n; - while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) body.append(buf, n); - std::fclose(f); + if (!readMagnetFiles(body)) return false; // A whole-line prefix "\t" -- at the start or right after a newline. std::string key = hash + "\t"; if (body.compare(0, key.size(), key) == 0) return true; @@ -529,12 +551,9 @@ void updateMagnetName(const std::string& magnet, const std::string& name) if (nl != std::string::npos) out += '\n'; } if (!changed) return; - if (FILE* w = std::fopen(APPDATA_MAGNETS, "wb")) - { - std::fwrite(out.data(), 1, out.size(), w); - std::fclose(w); + // Atomic: the user's own list, rewritten in place, was lost to a cut write. + if (fsutil::writeAtomic(APPDATA_MAGNETS, out)) brls::Logger::info("[magnet] saved name '{}' for {}", name, want); - } } std::vector scanTorrents(const std::string& dir) @@ -640,13 +659,8 @@ void cacheMagnetFiles(const std::string& hash, const std::vector& vid int64_t magnetCachedTotal(const std::string& hash) { if (hash.empty()) return 0; - FILE* f = std::fopen(APPDATA_MAGNET_FILES, "rb"); - if (!f) return 0; std::string body; - char buf[512]; - size_t n; - while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) body.append(buf, n); - std::fclose(f); + if (!readMagnetFiles(body)) return 0; std::string key = hash + "\t-2\t"; size_t k = body.find(key); @@ -663,13 +677,8 @@ std::vector magnetCachedFiles(const std::string& hash) { std::vector out; if (hash.empty()) return out; - FILE* f = std::fopen(APPDATA_MAGNET_FILES, "rb"); - if (!f) return out; std::string body; - char buf[512]; - size_t n; - while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) body.append(buf, n); - std::fclose(f); + if (!readMagnetFiles(body)) return out; size_t i = 0; std::string key = hash + "\t"; @@ -872,11 +881,8 @@ void removeMagnetFromFile(const std::string& magnet) if (nl != std::string::npos) out += '\n'; } } - if (FILE* w = std::fopen(APPDATA_MAGNETS, "wb")) - { - std::fwrite(out.data(), 1, out.size(), w); - std::fclose(w); - } + if (!fsutil::writeAtomic(APPDATA_MAGNETS, out)) + brls::Logger::warning("[magnet] could not write {}", APPDATA_MAGNETS); } // "Add magnet": the on-screen keyboard captures a magnet link or a bare hash. @@ -1031,7 +1037,10 @@ void resolveMagnetNames(std::shared_ptr alive, if (onStart) onStart(magnet); // its turn: show the spinner, drop "(waiting)" - brls::async([alive, todo, idx, magnet, onStart, onEach]() { + // On the worker pool: a dead magnet is up to 75 s of fetching, and on + // brls::async's single thread every save and the player's engine open + // queued behind it. torrent_meta_abort_all (app exit) ends it. + workers::run([alive, todo, idx, magnet, onStart, onEach]() { std::string name; int64_t total = 0; torrent_meta* m = (torrent_meta*)std::calloc(1, sizeof(torrent_meta)); @@ -2009,6 +2018,14 @@ int main(int argc, char* argv[]) // 1080p, and the two modes have their own UI size; follow it. brls::Application::getWindowSizeChangedEvent()->subscribe(applyUiScale); + // borealis keeps up to 200 textures no view uses any more, and a card's + // backdrop alone is 3.6 MB of pixels (w1280): a browse through the + // catalog held hundreds of MB next to mpv. Textures in use are never + // dropped whatever this is (it only bounds the unused ones), and a card + // coming back re-decodes its file off the UI thread (ui::loadImageAsync). + brls::TextureCache::instance().cache = + brls::LRUCache(kUnusedTextures, 0); + brls::Application::pushActivity(new brls::Activity(buildBrowser())); // First run without Jackett: its setup on top, once. maybeOfferJackettSetup(); @@ -2020,6 +2037,18 @@ int main(int argc, char* argv[]) if (r.ok && r.installable()) update::promptInstall(r); }); + // Application::exit(): exitEvent -> the activities are destroyed (the + // player's last progress save is queued on brls::async) -> the task thread + // is joined, dropping whatever is still queued -> exitDoneEvent. + // Abort the network first, so a long request on that thread (TorrServer, + // Stremio) does not hold the exit up; write the library last, so the + // dropped save is not lost. + brls::Application::getExitEvent()->subscribe([]() { + http::abortAll(); + torrent_meta_abort_all(); // magnet fetches on the worker pool + }); + brls::Application::getExitDoneEvent()->subscribe([]() { library::flush(); }); + // PC only: NX_AUTOTEST runs a scripted pass over the UI (app/autotest.hpp). bool scripted = autotest::init(); while (brls::Application::mainLoop()) @@ -2033,6 +2062,9 @@ int main(int argc, char* argv[]) // The network pool's threads, joined before main() returns (see // workers.hpp): a request still running past it would crash on the Switch. workers::stop(); + // The connections http::get/postJson kept open, now that every thread + // that used them is joined (borealis' task thread in Application::exit). + http::shutdown(); // The engine's DHT/listener/UPnP threads, for the same reason (and the DHT // saves its node cache for the next launch's warm start). diff --git a/app/player.cpp b/app/player.cpp index e2d3a90..931c027 100644 --- a/app/player.cpp +++ b/app/player.cpp @@ -52,6 +52,21 @@ extern "C" { } #include "i18n.hpp" +#include "workers.hpp" + +#include + +// An engine open in flight. `t` is set by the open task when it finishes and +// taken by whoever gets there first: the UI callback (normal case) or the +// destructor (Back pressed meanwhile). With `gone` set, the task closes what +// it opened itself -- also on app exit, where no brls::sync runs any more. +struct MpvView::EngineOpen +{ + std::mutex m; + bool gone = false; + torrentfs* t = nullptr; + volatile bool cancel = false; // torrentfs_open_file_cancel polls it +}; // See setMagnetResolvedHook (player.hpp): fired with the played magnet and its // resolved name so the Local tab can save a name it did not have yet. @@ -190,19 +205,22 @@ class PlayPauseButton : public brls::Box }; } // namespace -// Pops `n` activities back-to-back. A free function, not a member: the first pop -// destroys the player view, so `this` cannot drive the rest. Each pop chains the -// next from its completion callback, so they run in order without animation. -void popActivities(int n) +// The OS's "a video is playing" flag, counted per live player: switching +// episodes builds the next player before the previous one's destructor runs, +// and a plain true/false pair turned the flag off under the new episode. +void keepAwake(int delta) { - if (n <= 0) return; - brls::Application::popActivity(brls::TransitionAnimation::NONE, - [n]() { popActivities(n - 1); }); + static int players = 0; // UI thread only + int before = players; + players += delta; + if (players < 0) players = 0; + if ((before > 0) != (players > 0)) appletSetMediaPlaybackState(players > 0); } -// The same, with a final step. Each pop completes asynchronously, so whatever -// comes next has to hang off the last one's callback rather than run straight -// after the call. +// Pops `n` activities back-to-back, then runs `then`. A free function, not a +// member: the first pop destroys the player view, so `this` cannot drive the +// rest. Each pop completes asynchronously, so the next one (and `then`) hangs +// off the last one's callback rather than running straight after the call. void popActivitiesThen(int n, std::function then) { if (n <= 0) @@ -215,6 +233,29 @@ void popActivitiesThen(int n, std::function then) }); } +// Leaves the player whose activity is `player`, then pops `n - 1` more and +// runs `then`. Whatever sits above the player goes first: the settings panel +// is translucent, so the player keeps drawing (and reaching the end of the +// file) under it, and a count that assumed the player on top closed only the +// panel -- or pushed the next episode over a still-running player. +void popPlayerThen(brls::Activity* player, int n, std::function then) +{ + auto stack = brls::Application::getActivitiesStack(); + int above = -1; + for (int i = (int)stack.size() - 1; i >= 0; i--) + if (stack[i] == player) + { + above = (int)stack.size() - 1 - i; + break; + } + if (above < 0) + { + brls::Logger::warning("[player] already closed, nothing to pop"); + return; + } + popActivitiesThen(above + n, then); +} + } // namespace // Seconds -> "M:SS" (or "H:MM:SS" past an hour). @@ -318,7 +359,7 @@ MpvView::MpvView(const std::string& source, const PlayerArt& art, // Tell the OS a video is playing, so it does not dim the screen or sleep // during a long film -- there is no controller input while watching, which // is exactly what the idle timer counts. Cleared in the destructor. - appletSetMediaPlaybackState(true); + keepAwake(+1); this->setGrow(1.0f); this->setBackgroundColor(nvgRGB(0, 0, 0)); @@ -375,8 +416,11 @@ void MpvView::startEngine(const std::string& source, int fileIndex) if (direct) { if (statusLabel) statusLabel->setText(tr("Connecting...")); - if (!startMpv() && statusLabel) - statusLabel->setText(tr("Player initialisation failed")); + if (!startMpv()) + { + openFailed = true; // keep the reason on screen + if (statusLabel) statusLabel->setText(tr("Player initialisation failed")); + } return; } @@ -388,7 +432,10 @@ void MpvView::startEngine(const std::string& source, int fileIndex) if (statusLabel) statusLabel->setText(tr("Waiting for TorrServer...")); auto liveFlag = this->alive; std::string base = torrserver::base(); - brls::async([this, liveFlag, source, fileIndex, base]() { + // On the worker pool, not brls::async's single thread: the resolve + // can wait up to two minutes, and library/history saves queue there. + // http::abortAll (app exit) ends it. + workers::run([this, liveFlag, source, fileIndex, base]() { std::string url, err; std::string link = torrServerLink(source, err); bool ok = !link.empty() && @@ -407,8 +454,11 @@ void MpvView::startEngine(const std::string& source, int fileIndex) playUrl = url; direct = true; if (statusLabel) statusLabel->setText(tr("Connecting...")); - if (!startMpv() && statusLabel) - statusLabel->setText(tr("Player initialisation failed")); + if (!startMpv()) + { + openFailed = true; // keep the reason on screen + if (statusLabel) statusLabel->setText(tr("Player initialisation failed")); + } // The torrent's other files, for the episode list. fetchTorrServerPlaylist(link); }); @@ -424,19 +474,43 @@ void MpvView::startEngine(const std::string& source, int fileIndex) : "engine open (.torrent)"); auto liveFlag = this->alive; - brls::async([this, liveFlag, source, fileIndex]() { + auto job = std::make_shared(); + engineOpen = job; + // Latched into the engine at open; read from config here, on the UI thread. + int ramStream = config::get().ramStream ? 1 : 0; + int64_t streamWin = (int64_t)config::bufferPreset().streamWindowMiB * 1024 * 1024; + // On the worker pool: a magnet open is tens of seconds, and on + // brls::async it held up every save and the next player's open behind it. + workers::run([this, liveFlag, job, source, fileIndex, ramStream, streamWin]() { char err[256] = { 0 }; - // Latched into the engine at open; pick it up from config for this video. - torrentfs_set_ram_stream(config::get().ramStream ? 1 : 0); - torrentfs_set_stream_window((int64_t)config::bufferPreset().streamWindowMiB * - 1024 * 1024); - torrentfs* t = torrentfs_open_file(source.c_str(), APPDATA_CACHE, - fileIndex, err, sizeof(err)); + torrentfs_set_ram_stream(ramStream); + torrentfs_set_stream_window(streamWin); + // Cancellable: Back (the destructor) stops a magnet's metadata fetch + // instead of letting it run to its deadline. + torrentfs* t = torrentfs_open_file_cancel(source.c_str(), APPDATA_CACHE, + fileIndex, &job->cancel, err, + sizeof(err)); std::string e = err; + bool orphan; + { + std::lock_guard g(job->m); + orphan = job->gone; + if (!orphan) job->t = t; + } + if (orphan) + { + // The view is gone (or going): nobody else will close it. + if (t) torrentfs_close(t); + return; + } - brls::sync([this, liveFlag, t, e, source]() { - // B may have been pressed while we waited: `this` is gone and the - // engine we just opened would leak. + brls::sync([this, liveFlag, job, e, source]() { + torrentfs* t; + { + std::lock_guard g(job->m); + t = job->t; // null if the destructor took it + job->t = nullptr; + } if (!*liveFlag) { if (t) torrentfs_close(t); @@ -446,6 +520,7 @@ void MpvView::startEngine(const std::string& source, int fileIndex) { brls::Logger::error("torrentfs_open failed: {}", e); if (statusLabel) statusLabel->setText(std::string(tr("Failed: ")) + tr(e.c_str())); + openFailed = true; // keep the reason on screen return; } tfs = t; @@ -470,8 +545,11 @@ void MpvView::startEngine(const std::string& source, int fileIndex) } setPlaylistFiles(files); } - if (!startMpv() && statusLabel) - statusLabel->setText(tr("Player initialisation failed")); + if (!startMpv()) + { + openFailed = true; // keep the reason on screen + if (statusLabel) statusLabel->setText(tr("Player initialisation failed")); + } }); }); } @@ -610,8 +688,11 @@ bool MpvView::startMpv() } brls::Logger::info("mpv render context created OK"); - // Capture mpv's internal log so we can see vo/render/hwdec state. - mpv_request_log_messages(mpv, "v"); + // Capture mpv's internal log so we can see vo/render/hwdec state -- in + // full only with the log file on: at "v" mpv formats a few hundred lines + // a minute, each drained and turned into strings here every frame, to be + // dropped by the logger anyway. Warnings and errors always. + mpv_request_log_messages(mpv, config::get().logging ? "v" : "warn"); // Resume where the Stremio account left off: mpv seeks there right after // the open, and the engine's playhead (stream.c seek_cb) follows, so the @@ -646,7 +727,7 @@ bool MpvView::startMpv() if (url.rfind("https://", 0) == 0) { httpsrc_set_proxy(proxy.c_str()); - stream_register_http(mpv); + stream_register_http(mpv, &httpAbort); url = "nx" + url; } else if (proxy.rfind("http://", 0) == 0) @@ -659,6 +740,7 @@ bool MpvView::startMpv() brls::Logger::warning("[player] a socks proxy cannot carry an http:// " "stream; connecting directly"); } + mpvUrl = url; const char* cmd[] = { "loadfile", url.c_str(), nullptr }; mpv_command(mpv, cmd); brls::Logger::info("loadfile direct stream from {}", urlHost(playUrl)); @@ -804,6 +886,8 @@ bool MpvView::command(Cmd c) return true; } } + else + unlockPresses = 0; // in a row: any other button starts over flashLock(); return true; } @@ -1084,6 +1168,9 @@ void MpvView::commitSeek() const char* cmd[] = { "seek", t, "absolute", nullptr }; mpv_command_async(mpv, 0, cmd); obsPos = seekTarget; // the bar shows the landing at once (obsPos is async) + // And it is where playback is now: the samplers only take a position + // above 0, so a seek back to the start used to save the old one. + lastPosSec = seekTarget; brls::Logger::info("[player] seek -> {}s", (int)seekTarget); // A scrub that paused the picture to aim resumes it; the D-pad steps never // touched the play state, so it stays whatever the user had. @@ -1581,8 +1668,8 @@ void MpvView::goToEpisode(int dir) int pops = watch.nextPop; brls::Logger::info("[player] previous episode -> S{}E{} (pop {})", season, episode, pops); - brls::sync([pops, item, season, episode]() { - popActivitiesThen(pops, [item, season, episode]() { + brls::sync([act = getParentActivity(), pops, item, season, episode]() { + popPlayerThen(act, pops, [item, season, episode]() { openNextEpisode(item, season, episode); }); }); @@ -1593,8 +1680,8 @@ void MpvView::goToEpisode(int dir) PlayerArt showArt = art; int pops = watch.endPop; brls::Logger::info("[player] previous episode -> {} (pop {})", prev, pops); - brls::sync([pops, authKey, series, prev, showArt]() { - popActivitiesThen(pops, [authKey, series, prev, showArt]() { + brls::sync([act = getParentActivity(), pops, authKey, series, prev, showArt]() { + popPlayerThen(act, pops, [authKey, series, prev, showArt]() { openEpisodeById(authKey, series, prev, showArt); }); }); @@ -1625,8 +1712,8 @@ void MpvView::goToNextEpisode() content::Item item = watch.content; int season = watch.season, episode = watch.episode + 1; brls::Logger::info("[player] next episode -> S{}E{} (pop {})", season, episode, pops); - brls::sync([pops, item, season, episode]() { - popActivitiesThen(pops, [item, season, episode]() { + brls::sync([act = getParentActivity(), pops, item, season, episode]() { + popPlayerThen(act, pops, [item, season, episode]() { openNextEpisode(item, season, episode); }); }); @@ -1636,8 +1723,8 @@ void MpvView::goToNextEpisode() brls::Logger::info("[player] next episode -> {} (pop {})", next, pops); // Deferred: we are inside draw()'s update pass (or a tap handler), and // popping -- which frees this view -- mid-draw is not safe. - brls::sync([pops, authKey, series, next, showArt]() { - popActivitiesThen(pops, [authKey, series, next, showArt]() { + brls::sync([act = getParentActivity(), pops, authKey, series, next, showArt]() { + popPlayerThen(act, pops, [authKey, series, next, showArt]() { openEpisodeById(authKey, series, next, showArt); }); }); @@ -1778,6 +1865,20 @@ void MpvView::playPlaylistItem(int i) std::to_string(e); w.displayTitle = title; } + else + { + // Not an episode we can name (an extra, a sample, a file of a film's + // torrent): it plays as a plain file, tracked by source and file + // index in the history. Keeping the previous episode's identity + // wrote this file's position over that episode's progress, in the + // library and on the Stremio account, and offered to resume it here. + w.itemId.clear(); + w.videoId.clear(); + w.type.clear(); + w.content = content::Item(); + w.season = 0; + w.episode = 0; + } switching = true; // the EOF handler must not pop under the swap nextCardShown = false; @@ -1787,8 +1888,8 @@ void MpvView::playPlaylistItem(int i) std::string type = currentSourceType; int idx = f.fileIndex; brls::Logger::info("[player] episode list -> file {} ({})", idx, f.label); - brls::sync([src, a, title, idx, w, type]() { - popActivitiesThen(1, [src, a, title, idx, w, type]() { + brls::sync([act = getParentActivity(), src, a, title, idx, w, type]() { + popPlayerThen(act, 1, [src, a, title, idx, w, type]() { startPlaybackPrompt(src, a, title, idx, w, type); }); }); @@ -2143,8 +2244,9 @@ void MpvView::openEpisodesPanel() else { history::Item h; - if (history::find(currentSource, p.fileIndex, "", h) || - history::find(currentSource, p.fileIndex, watch.videoId, h)) + // By source and file only: matching the playing episode's + // videoId too gave every unplayed file its progress. + if (history::find(currentSource, p.fileIndex, "", h)) { if (h.completed) d = tr("Watched"); @@ -2438,8 +2540,9 @@ MpvView::~MpvView() { brls::Logger::info("[teardown] ~MpvView enter"); - // Playback is ending: let the OS dim/sleep on idle again. - appletSetMediaPlaybackState(false); + // Playback is ending: let the OS dim/sleep on idle again -- unless the + // next episode's player, built before this one is destroyed, is up. + keepAwake(-1); // Last chance to tell Stremio where playback stopped. Uses the position // sampled during playback (see logStats) -- mpv is about to be destroyed, @@ -2451,6 +2554,18 @@ MpvView::~MpvView() // Tell any in-flight startEngine() that we are gone, so it closes the engine // it opened instead of writing into a destroyed view. *alive = false; + if (engineOpen) + { + torrentfs* orphan; + { + std::lock_guard g(engineOpen->m); + engineOpen->gone = true; + engineOpen->cancel = true; // a magnet stops fetching metadata + orphan = engineOpen->t; // opened, callback not run yet + engineOpen->t = nullptr; + } + if (orphan) torrentfs_close(orphan); + } // Cancel first so the mpv demuxer's parked read returns (~20 ms); mpv's // shutdown touches GL, so it MUST stay on this (UI/GL) thread -- doing it on @@ -2462,6 +2577,9 @@ MpvView::~MpvView() engine_unregister_torrent(tfs); torrentfs_cancel(tfs); } + // An nxhttps:// open still connecting: mpv_terminate_destroy would wait + // it out on this (UI) thread. + httpAbort = true; if (renderCtx) mpv_render_context_free(renderCtx); if (mpv) @@ -2553,6 +2671,41 @@ void MpvView::pumpEvents() openFailed = true; break; } + // The stream died mid-file: a link that stopped answering + // reaches mpv as the end of the file (or an error), and it + // used to count as watched to the end -- marked finished, + // the next episode started. Near the end it is the end; + // anywhere else, reconnect where it stopped, a few times. + if (ef && fileLoaded && !ended && !switching && + (ef->reason == MPV_END_FILE_REASON_EOF || + ef->reason == MPV_END_FILE_REASON_ERROR)) + { + double dur = obsDur > 0 ? obsDur : lastDurSec; + double pos = obsPos > 0 ? obsPos : lastPosSec; + bool nearEnd = dur <= 0 || pos >= dur - 15.0; + if (!nearEnd && direct && !mpvUrl.empty() && streamRetries < 3) + { + streamRetries++; + brls::Logger::warning( + "[mpv event] stream ended at {:.0f}/{:.0f}s -> reconnect {}", + pos, dur, streamRetries); + flashPill(tr("Connection lost, reconnecting..."), 3000); + std::string start = std::to_string((long long)pos); + mpv_set_property_string(mpv, "start", start.c_str()); + const char* cmd[] = { "loadfile", mpvUrl.c_str(), nullptr }; + mpv_command_async(mpv, 0, cmd); + break; + } + if (!nearEnd) + { + brls::Logger::error( + "[mpv event] stream ended at {:.0f}/{:.0f}s, giving up", pos, + dur); + flashPill(tr("Playback stopped: the connection was lost"), 600000); + openFailed = true; // B leaves at once; not marked watched + break; + } + } if (ef && ef->reason == MPV_END_FILE_REASON_EOF && !ended && !switching) { // An episode with one after it goes on to it, as Lampa @@ -2573,7 +2726,9 @@ void MpvView::pumpEvents() brls::Logger::info("[mpv event] EOF -> pop {}", pops); // Deferred: we are inside draw()'s pumpEvents, and popping // (which frees this view) mid-draw is unsafe. - brls::sync([pops]() { popActivities(pops); }); + brls::sync([act = getParentActivity(), pops]() { + popPlayerThen(act, pops, nullptr); + }); } break; } diff --git a/app/player.hpp b/app/player.hpp index 155616b..d4f2871 100644 --- a/app/player.hpp +++ b/app/player.hpp @@ -177,6 +177,11 @@ class MpvView : public brls::Box // (BEP 9) -- seconds of blocking work. It runs on a background thread, so // this guards against the result landing after the view is gone. std::shared_ptr alive = std::make_shared(true); + // The engine open in flight (player.cpp): its cancel flag, and the + // hand-off that makes exactly one of the open task, its UI callback and + // the destructor close an engine that opened after Back. + struct EngineOpen; + std::shared_ptr engineOpen; std::string pendingSource; void updateLoadingOverlay(); // Seconds of video to buffer before the first frame: the Options preset at @@ -410,6 +415,9 @@ class MpvView : public brls::Box bool pictureDirty = false; torrentfs* tfs = nullptr; + // Set by the destructor: abandons an nxhttps:// open in progress + // (stream_register_http). Outlives mpv, which is destroyed first. + volatile bool httpAbort = false; // The source is an http(s) URL played as is (a Stremio url stream, a // TorrServer link), not a torrent: there is no engine, tfs stays null and // mpv reads the URL -- https through our nxhttps:// stream (stream.c), @@ -513,6 +521,10 @@ class MpvView : public brls::Box bool ready = false; // mpv has presented the first video frame bool fileLoaded = false; // mpv opened the stream (header downloaded) + // A direct stream's URL as given to mpv, and how many times it has been + // reopened after dying mid-file (MPV_EVENT_END_FILE). + std::string mpvUrl; + int streamRetries = 0; bool overlayHidden = false; // loading screen has been taken down bool ended = false; // reached EOF; the auto-close is scheduled // Leaving for another episode (previous / next / the list): the player is diff --git a/app/release.cpp b/app/release.cpp index 1f5a270..422b450 100644 --- a/app/release.cpp +++ b/app/release.cpp @@ -101,8 +101,10 @@ void skipSpaces(const std::string& s, size_t& i) i++; } -// "1-8", "1–8" (an en dash), "1 - 8": the end of a range after a number. -int readRangeEnd(const std::string& s, size_t& i) +// "1-8", "1–8" (an en dash), "1 - 8": the end of a range after a number +// `from`, at most `maxv`. Not a range: "S01E05 - 720p" (a letter right after +// the number -- a resolution, a codec), and an end below its start. +int readRangeEnd(const std::string& s, size_t& i, int from, int maxv) { size_t j = i; while (j < s.size() && s[j] == ' ') j++; @@ -115,7 +117,10 @@ int readRangeEnd(const std::string& s, size_t& i) while (j < s.size() && s[j] == ' ') j++; if (j < s.size() && (s[j] == 'e' || s[j] == 's')) j++; // "E01-E08", "S01-S03" int n = readNum(s, j); - if (n >= 0) i = j; + if (n < 0) return -1; + if (j < s.size() && std::isalpha((unsigned char)s[j])) return -1; // 720p, 264 + if (n < from || n > maxv) return -1; + i = j; return n; } @@ -171,12 +176,12 @@ void readScope(const std::string& low, Info& in) j++; int e = readNum(low, j); in.episode = in.episodeTo = e; - int to = readRangeEnd(low, j); + int to = readRangeEnd(low, j, e, 999); if (to >= 0) in.episodeTo = to; } else { - int to = readRangeEnd(low, j); + int to = readRangeEnd(low, j, s, 99); if (to >= 0) in.seasonTo = to; } } @@ -214,7 +219,7 @@ void readScope(const std::string& low, Info& in) if (s < 0) s = numberBefore(low, k); if (s < 0) continue; in.season = in.seasonTo = s; - int to = readRangeEnd(low, j); + int to = readRangeEnd(low, j, s, 99); if (to >= 0) in.seasonTo = to; } } @@ -234,7 +239,7 @@ void readScope(const std::string& low, Info& in) if (e < 0) e = numberBefore(low, k); if (e < 0) continue; in.episode = in.episodeTo = e; - int to = readRangeEnd(low, j); + int to = readRangeEnd(low, j, e, 999); if (to >= 0) in.episodeTo = to; // "из 8" / "of 8": the whole season when the range reaches it. size_t of = low.find("\xD0\xB8\xD0\xB7 ", j); // "из " diff --git a/app/settings.cpp b/app/settings.cpp index 616ac5c..4f3fd56 100644 --- a/app/settings.cpp +++ b/app/settings.cpp @@ -753,19 +753,21 @@ brls::View* networkPane() } tmdbTest->setState(brls::ButtonState::DISABLED); tmdbTest->setText(tr("Testing...")); - // A cleared cache makes it a real request rather than a stored answer. - tmdb::clearCache(); - tmdb::fetchRowAsync(tmdb::Row::Trending, [tmdbTest, live = tmdbTest->alive]( - tmdb::ListResult r) { - if (*live) - { - tmdbTest->setState(brls::ButtonState::ENABLED); - tmdbTest->setText(tr("Test connection")); - } - if (r.ok && !r.items.empty()) - note(tr("Connection successful!")); - else - note(tr("Connection failed: ") + (r.error.empty() ? tr("empty answer") : r.error)); + // A cleared cache makes it a real request rather than a stored answer: + // the request goes out once the clearing is done. + tmdb::clearCache([tmdbTest, live = tmdbTest->alive]() { + tmdb::fetchRowAsync(tmdb::Row::Trending, [tmdbTest, live](tmdb::ListResult r) { + if (*live) + { + tmdbTest->setState(brls::ButtonState::ENABLED); + tmdbTest->setText(tr("Test connection")); + } + if (r.ok && !r.items.empty()) + note(tr("Connection successful!")); + else + note(tr("Connection failed: ") + + (r.error.empty() ? tr("empty answer") : r.error)); + }); }); return true; }); diff --git a/app/sources.cpp b/app/sources.cpp index 521e7ab..1aebf96 100644 --- a/app/sources.cpp +++ b/app/sources.cpp @@ -14,6 +14,7 @@ #include "appdata.hpp" #include "browse.hpp" // stremioStreamSource +#include "fsutil.hpp" #include "config.hpp" #include "i18n.hpp" #include "jackett.hpp" @@ -177,11 +178,9 @@ void saveChoice(const std::string& key, const std::string& voice, const std::str lines.push_back(key + "\t" + voice + "\t" + group); // A few hundred shows at most; the oldest go first. if (lines.size() > 300) lines.erase(lines.begin(), lines.end() - 300); - if (FILE* f = std::fopen(kChoices, "wb")) - { - for (const auto& l : lines) std::fprintf(f, "%s\n", l.c_str()); - std::fclose(f); - } + std::string out; + for (const auto& l : lines) out += l + "\n"; + fsutil::writeAtomic(kChoices, out); } // --------------------------------------------------------------------------- diff --git a/app/stremio.cpp b/app/stremio.cpp index a28aff3..d04c162 100644 --- a/app/stremio.cpp +++ b/app/stremio.cpp @@ -34,6 +34,7 @@ #include "appdata.hpp" #include "browse.hpp" +#include "fsutil.hpp" #include "config.hpp" #include "http.hpp" #include "json.hpp" @@ -201,6 +202,11 @@ std::string oneLine(std::string s, size_t max = 120) std::string webpToPng(const std::string& in) { int w = 0, h = 0; + // The header first: a poster is at most a few thousand pixels a side, + // and a hostile file declaring 16k x 16k would be a 1 GB allocation. + if (!WebPGetInfo((const uint8_t*)in.data(), in.size(), &w, &h) || w <= 0 || h <= 0 || + w > 4096 || h > 4096) + return ""; uint8_t* rgba = WebPDecodeRGBA((const uint8_t*)in.data(), in.size(), &w, &h); if (!rgba) return ""; @@ -340,15 +346,28 @@ static bool g_libFetched = false; LocalWatch lastWatch() { return g_lastWatch; } -bool saveAuthKey(const std::string& key) +// An addon's JSON: http::get succeeds on any answer, and an addon's 500 page +// was read (and cached for the session) as an empty result -- "No episodes +// found", no genres, no streams. Anything but 2xx is an error here. +static bool getJson(const std::string& url, std::string& resp, std::string& err) { - FILE* f = std::fopen(kKeyPath, "w"); - if (!f) return false; - std::fwrite(key.data(), 1, key.size(), f); - std::fclose(f); + long status = 0; + if (!http::get(url, resp, err, nullptr, 25, &status)) return false; + if (status != 0 && (status < 200 || status >= 300)) + { + err = "HTTP " + std::to_string(status); + resp.clear(); + return false; + } return true; } +bool saveAuthKey(const std::string& key) +{ + // Atomic: rewritten in place, a cut write signed the user out. + return fsutil::writeAtomic(kKeyPath, key); +} + std::string loadAuthKey() { FILE* f = std::fopen(kKeyPath, "r"); @@ -370,11 +389,7 @@ void clearAuthKey() bool saveEmail(const std::string& email) { - FILE* f = std::fopen(kEmailPath, "w"); - if (!f) return false; - std::fwrite(email.data(), 1, email.size(), f); - std::fclose(f); - return true; + return fsutil::writeAtomic(kEmailPath, email); } std::string loadEmail() @@ -614,6 +629,7 @@ void fetchLibraryAsync(const std::string& authKey, { brls::async([authKey, done]() { LibraryResult r; + std::shared_ptr> ids; // the account's ids, if read std::string body = "{\"authKey\":\"" + json::escape(authKey) + "\",\"collection\":\"libraryItem\",\"all\":true}"; std::string resp, err; @@ -682,13 +698,14 @@ void fetchLibraryAsync(const std::string& authKey, // holds, whether or not it survived the filters above -- a // "temp" (auto-added by watching) item IS in the library as far // as the +/- button is concerned; only "removed" is not. - g_libIds.clear(); - g_libFetched = true; + // Built here, published on the UI thread below: g_libIds is + // read and edited there (inLibrary, the +/- button). + ids = std::make_shared>(); for (const auto& o : objs) { std::string id = json::str(o, "_id"); if (!id.empty() && !json::boolean(o, "removed", false)) - g_libIds.insert(id); + ids->insert(id); } // Most recently viewed first: watching bumps _mtime, and the ISO @@ -707,7 +724,14 @@ void fetchLibraryAsync(const std::string& authKey, nTemp); } } - brls::sync([done, r]() { done(r); }); + brls::sync([done, r, ids]() { + if (ids) + { + g_libIds = std::move(*ids); + g_libFetched = true; + } + done(r); + }); }); } @@ -1487,7 +1511,7 @@ void fetchMetaAsync(const std::string& addonBase, const std::string& type, std::string url = addonBase + "/meta/" + http::urlEncode(type) + "/" + http::urlEncode(id) + ".json"; std::string resp, err; - if (!http::get(url, resp, err)) + if (!getJson(url, resp, err)) { r.error = err; } @@ -1554,7 +1578,7 @@ void fetchStreamsAsync(const std::string& addonBase, const std::string& type, std::string url = addonBase + "/stream/" + http::urlEncode(type) + "/" + http::urlEncode(id) + ".json"; std::string resp, err; - if (!http::get(url, resp, err)) + if (!getJson(url, resp, err)) { r.error = err; } @@ -1642,7 +1666,10 @@ void fetchSubtitlesAsync(const std::string& authKey, const std::string& type, return; } - workers::run([list, type, videoId, done]() { + // Read here, on the UI thread: Options may be assigning it meanwhile. + std::string want = config::preferredSubLang(); + std::string wantLabel = config::langLabelFor(want); + workers::run([list, type, videoId, done, want, wantLabel]() { SubtitlesResult r; // One addon at a time rather than in parallel. These run while the // engine is streaming, and the console's socket pool is what the @@ -1654,7 +1681,7 @@ void fetchSubtitlesAsync(const std::string& authKey, const std::string& type, std::string url = a.base + "/subtitles/" + http::urlEncode(type) + "/" + http::urlEncode(videoId) + ".json"; std::string resp, err; - if (!http::get(url, resp, err)) + if (!getJson(url, resp, err)) { brls::Logger::warning("[stremio] subtitles from {} failed: {}", a.name, err); @@ -1685,8 +1712,6 @@ void fetchSubtitlesAsync(const std::string& authKey, const std::string& type, // The preferred language first -- stable, so each language keeps the // order its addon sent (which is roughly best-match first). - std::string want = config::preferredSubLang(); - std::string wantLabel = config::langLabelFor(want); std::stable_sort(r.subs.begin(), r.subs.end(), [&](const Subtitle& a, const Subtitle& b) { bool pa = config::langLabelFor(a.lang) == wantLabel; @@ -1713,14 +1738,41 @@ void downloadSubtitleAsync(const Subtitle& sub, return; } + // A second request for a file still downloading waits for that one + // (two taps wrote the same file at once). UI thread only. + static std::map>> inFlight; + auto it = inFlight.find(path); + if (it != inFlight.end()) + { + it->second.push_back(done); + return; + } + inFlight[path].push_back(done); + std::string url = sub.url; - workers::run([url, path, done]() { + workers::run([url, path]() { + // Into .part: the cache counts any file at `path` as a hit, so a + // download cut short there was served, truncated, from then on. + std::string part = path + ".part"; std::string err; - bool ok = http::download(url, path, err); + bool ok = http::download(url, part, err); + if (ok) + { + std::remove(path.c_str()); + ok = std::rename(part.c_str(), path.c_str()) == 0; + if (!ok) err = "cannot rename"; + } if (!ok) + { + std::remove(part.c_str()); brls::Logger::warning("[stremio] subtitle download failed: {}", err); + } std::string out = ok ? path : std::string(); - brls::sync([done, out]() { done(out); }); + brls::sync([path, out]() { + auto waiting = std::move(inFlight[path]); + inFlight.erase(path); + for (auto& cb : waiting) cb(out); + }); }); } @@ -1768,7 +1820,8 @@ void fetchCatalogGenresAsync(const std::string& addonBase, workers::run([addonBase, type, catalogId, key, done]() { std::vector genres; std::string resp, err; - if (!http::get(addonBase + "/manifest.json", resp, err)) + bool fetched = getJson(addonBase + "/manifest.json", resp, err); + if (!fetched) { brls::Logger::warning("[stremio] manifest {} failed: {}", addonBase, err); @@ -1791,9 +1844,10 @@ void fetchCatalogGenresAsync(const std::string& addonBase, genres.size()); } // Cached even when empty: a catalog with no genres must not be asked - // again every time its section is opened. - brls::sync([key, genres, done]() { - genreCache[key] = genres; + // again every time its section is opened. A failed request is not + // an answer, though: the next visit asks again. + brls::sync([key, genres, done, fetched]() { + if (fetched) genreCache[key] = genres; done(genres); }); }); @@ -1814,7 +1868,7 @@ void fetchCatalogAsync(const std::string& addonBase, const std::string& type, LibraryResult r; std::string url = catalogUrl(addonBase, type, catalogId, query); std::string resp, err; - if (!http::get(url, resp, err)) + if (!getJson(url, resp, err)) { r.error = err; } @@ -1855,7 +1909,7 @@ void fetchSearchAsync(const std::string& query, std::string url = std::string("https://v3-cinemeta.strem.io/catalog/") + type + "/top/search=" + http::urlEncode(query) + ".json"; std::string resp, err; - if (!http::get(url, resp, err)) continue; // one type may fail; keep the other + if (!getJson(url, resp, err)) continue; // one type may fail; keep the other for (const auto& o : json::objects(resp, "metas")) { LibItem it; @@ -2327,6 +2381,8 @@ void StremioTab::draw(NVGcontext* vg, float x, float y, float width, float heigh std::chrono::steady_clock::now().time_since_epoch()) .count(); + renderWhenOnTop(); + // Analog stick flick cycles the view too (Left/Right buttons are actions, // but the stick is an axis borealis does not turn into button presses here). // Only while focus is in the list, one cycle per flick, and deferred to a @@ -2742,12 +2798,28 @@ void StremioTab::loadCatalog(const char* type, } cache = r.items; loaded = true; + if (!ui::isOnTop(this)) + { + renderPending = true; // renderView() shows it from the cache + return; + } showItems(cache, header, t == "series" ? tr("No popular shows") : tr("No popular movies")); }); } +// Called from draw(): the rebuild itself runs from sync, off the draw pass +// (rebuilding the tree mid-draw crashes). +void StremioTab::renderWhenOnTop() +{ + if (!renderPending || !ui::isOnTop(this)) return; + renderPending = false; + brls::sync([this, live = alive, want = view]() { + if (*live && view == want && ui::isOnTop(this)) renderView(); + }); +} + // The heading over the current view's strip. Plain words, unlike the header // line at the top of the screen, which carries a glyph and a count. const char* StremioTab::sectionTitle() const @@ -2788,7 +2860,11 @@ void StremioTab::loadFeatured(const char* type) cache = r.items; // Only if the user is still on the view that asked for it: the // render folds the new section in. - if (view == want) renderView(); + if (view != want) return; + if (ui::isOnTop(this)) + renderView(); + else + renderPending = true; }); } diff --git a/app/stremio.hpp b/app/stremio.hpp index 2fe2ddf..db15d76 100644 --- a/app/stremio.hpp +++ b/app/stremio.hpp @@ -490,6 +490,11 @@ class StremioTab : public brls::Box // failing addon costs one request rather than one per visit. std::vector featMovies, featSeries; bool featMoviesAsked = false, featSeriesAsked = false; + // A catalog landed while a detail screen (or a dialog) was above us: the + // card that opened it sits on the focus stack, so the rebuild waits until + // we are on top again (draw()). + bool renderPending = false; + void renderWhenOnTop(); std::string email; std::string password; diff --git a/app/tmdb.cpp b/app/tmdb.cpp index 710360c..5d72ddd 100644 --- a/app/tmdb.cpp +++ b/app/tmdb.cpp @@ -456,7 +456,8 @@ bool fetchCached(const Settings& s, const std::string& url, long ttlSec, std::string auth; if (isBearerToken(s.apiKey)) auth = "Authorization: Bearer " + s.apiKey; std::string resp; - bool ok = http::get(url, resp, err, auth.empty() ? nullptr : auth.c_str(), 20); + long status = 0; + bool ok = http::get(url, resp, err, auth.empty() ? nullptr : auth.c_str(), 20, &status); if (!ok) { // One more try: a flaky connection (Wi-Fi waking, a VPN or proxy @@ -464,7 +465,8 @@ bool fetchCached(const Settings& s, const std::string& url, long ttlSec, // This runs on a worker thread, so the pause blocks nothing. std::this_thread::sleep_for(std::chrono::milliseconds(800)); resp.clear(); - ok = http::get(url, resp, err, auth.empty() ? nullptr : auth.c_str(), 20); + status = 0; + ok = http::get(url, resp, err, auth.empty() ? nullptr : auth.c_str(), 20, &status); } if (ok) { @@ -474,6 +476,13 @@ bool fetchCached(const Settings& s, const std::string& url, long ttlSec, err = "TMDB: " + tmdbErr; ok = false; } + // http::get succeeds on any answer: a proxy's 502 page or a 429 is + // not TMDB's, and cached it replaced the good copy for hours. + else if (status != 0 && (status < 200 || status >= 300)) + { + err = "HTTP " + std::to_string(status); + ok = false; + } } if (ok) { @@ -483,13 +492,20 @@ bool fetchCached(const Settings& s, const std::string& url, long ttlSec, std::to_string(std::hash()(std::this_thread::get_id()) % 100000) + ".tmp"; + bool written = false; if (FILE* f = std::fopen(tmp.c_str(), "wb")) { - std::fwrite(resp.data(), 1, resp.size(), f); - std::fclose(f); + written = std::fwrite(resp.data(), 1, resp.size(), f) == resp.size(); + written = std::fclose(f) == 0 && written; + } + if (written) + { std::remove(path.c_str()); - std::rename(tmp.c_str(), path.c_str()); + written = std::rename(tmp.c_str(), path.c_str()) == 0; } + // A short write (full card) or the loser of a race on the same URL: + // keep the old copy, leave no .tmp behind. + if (!written) std::remove(tmp.c_str()); body = std::move(resp); return true; } @@ -656,19 +672,22 @@ int64_t cacheBytes() return total; } -void clearCache() +void clearCache(std::function done) { - if (DIR* d = opendir(APPDATA_TMDB)) - { - struct dirent* e; - while ((e = readdir(d))) + workers::run([done]() { + if (DIR* d = opendir(APPDATA_TMDB)) { - std::string name = e->d_name; - if (name == "." || name == "..") continue; - std::remove((std::string(APPDATA_TMDB) + "/" + name).c_str()); + struct dirent* e; + while ((e = readdir(d))) + { + std::string name = e->d_name; + if (name == "." || name == "..") continue; + std::remove((std::string(APPDATA_TMDB) + "/" + name).c_str()); + } + closedir(d); } - closedir(d); - } + if (done) brls::sync(done); + }); } #endif // TMDB_NO_UI diff --git a/app/tmdb.hpp b/app/tmdb.hpp index 29218f3..f96ba13 100644 --- a/app/tmdb.hpp +++ b/app/tmdb.hpp @@ -172,6 +172,8 @@ void fetchSeasonAsync(int tvId, int season, std::function do // Size of the response cache on the SD card, and emptying it. int64_t cacheBytes(); -void clearCache(); +// Deletes the cached answers, on a worker (a directory walk on the SD card); +// `done`, if given, runs on the UI thread afterwards. +void clearCache(std::function done = nullptr); } // namespace tmdb diff --git a/app/torrent_play.cpp b/app/torrent_play.cpp index d5b7d1a..cf1e811 100644 --- a/app/torrent_play.cpp +++ b/app/torrent_play.cpp @@ -216,7 +216,11 @@ void resolveMagnet(const std::shared_ptr& job, const std::string& magnet) // and the peers found here are cached by info-hash, so the player // opening it next starts from them instead of the network. auto t0 = std::chrono::steady_clock::now(); - rc = torrent_load_magnet(m, magnet.c_str(), err, sizeof(err)); + // Cancel (B on the loading box, or its destruction on exit) + // stops the fetch instead of letting it run to its deadline. + rc = torrent_load_magnet_cancel( + m, magnet.c_str(), (const volatile bool*)job->cancelled.get(), err, + sizeof(err)); brls::Logger::info( "[startup] magnet resolved in {} ms ({})", (long long)std::chrono::duration_cast( @@ -303,6 +307,22 @@ void playJackettItem(const jackett::Item& item, const PlayHint& hint) // One file per link: history entries resume from this path later, so it // must not be overwritten by the next result played. std::string tempPath = jackett::cachedTorrentPath(link); + // Played before: the file is already here (and history or Continue may + // point at it). No refetch -- which also works with Jackett offline. + { + auto vids = parseTorrentVideos(tempPath); + if (!vids.empty()) + { + brls::Logger::info("[play] using the cached {}", tempPath); + brls::sync([job, tempPath, vids]() { + if (*job->cancelled) return; + finish(job, [job, tempPath, vids]() { + playWithVideos(job->hint, tempPath, vids); + }); + }); + return; + } + } jackett::downloadTorrentAsync(link, tempPath, [job, tempPath, magnet]( bool ok, const std::string& err, const std::string& redirected) { diff --git a/app/update.cpp b/app/update.cpp index f3ad00d..caa9934 100644 --- a/app/update.cpp +++ b/app/update.cpp @@ -14,6 +14,7 @@ #include "http.hpp" #include "json.hpp" #include "i18n.hpp" +#include "workers.hpp" #ifndef APP_VERSION #define APP_VERSION "0.0.0" @@ -38,6 +39,28 @@ std::string pendingPath() return selfNro.empty() ? "" : selfNro + ".new"; } +// A whole .nro, not a truncated download or an error page served with 200: +// "NRO0" at 0x10, and the file at least as long as the header's own size +// field (0x18; the assets -- icon, nacp, romfs -- follow it). `expect` is the +// release's asset size when the API gave one. +bool validNro(const std::string& path, int64_t expect) +{ + FILE* f = std::fopen(path.c_str(), "rb"); + if (!f) return false; + unsigned char h[0x20]; + bool ok = std::fread(h, 1, sizeof(h), f) == sizeof(h) && + std::memcmp(h + 0x10, "NRO0", 4) == 0; + int64_t len = -1; + if (ok && std::fseek(f, 0, SEEK_END) == 0) len = (int64_t)std::ftell(f); + std::fclose(f); + if (!ok || len < 0) return false; + uint32_t hdrSize = (uint32_t)h[0x18] | ((uint32_t)h[0x19] << 8) | + ((uint32_t)h[0x1A] << 16) | ((uint32_t)h[0x1B] << 24); + if (hdrSize < 0x80 || len < (int64_t)hdrSize) return false; + if (expect > 0 && len != expect) return false; + return true; +} + // "v1.2.3" / "1.2" / "1.2.3-beta" -> {1,2,3}. Anything unparsable reads as 0, // which makes a malformed tag look older than us rather than newer -- a bad tag // should not push an update at everyone. @@ -90,13 +113,11 @@ bool hasPending() { std::string p = pendingPath(); if (p.empty()) return false; - // The file, not a flag: a download that survived a crash is still good, and - // gets applied on the next clean exit. - if (FILE* f = std::fopen(p.c_str(), "rb")) - { - std::fclose(f); - return true; - } + // The file, not a flag. Downloads land in .part and are renamed here only + // once complete, but check it anyway: installing a broken .nro leaves the + // user with an app that no longer starts. + if (validNro(p, 0)) return true; + std::remove(p.c_str()); // missing or broken: nothing to apply return false; } @@ -292,13 +313,20 @@ void downloadAsync(const Release& r, std::function progress, return; } - brls::async([url, dest, progress, done]() { + int64_t expect = r.size; + // Tens of MB: on the worker pool, not brls::async's one thread, where + // the library and history saves would wait for the whole download. + workers::run([url, dest, expect, progress, done]() { // The callback runs on this thread, per curl chunk. Marshal to the UI // thread, but only when the whole percent changes -- a sync() per chunk // would queue thousands of no-op UI updates. auto last = std::make_shared(-1); std::string err; - bool ok = http::download(url, dest, err, + // Into .part first: a power cut or a HOME close mid-download must not + // leave a .new behind, which the next clean exit would install. + std::string part = dest + ".part"; + std::remove(dest.c_str()); + bool ok = http::download(url, part, err, [progress, last](int64_t now, int64_t total) { float f = total > 0 ? (float)now / total : -1.0f; int pct = total > 0 ? (int)(f * 100) : -1; @@ -309,6 +337,17 @@ void downloadAsync(const Release& r, std::function progress, } return true; }); + if (ok && !validNro(part, expect)) + { + ok = false; + err = "the download is not a complete .nro"; + } + if (ok && std::rename(part.c_str(), dest.c_str()) != 0) + { + ok = false; + err = std::string("cannot rename: ") + std::strerror(errno); + } + if (!ok) std::remove(part.c_str()); std::string e = ok ? "" : err; if (!ok) brls::Logger::error("[update] download failed: {}", err); brls::sync([done, e]() { done(e); }); diff --git a/app/widgets.cpp b/app/widgets.cpp index fe4641c..03c9d1d 100644 --- a/app/widgets.cpp +++ b/app/widgets.cpp @@ -3,6 +3,10 @@ #include "i18n.hpp" #include "stremio.hpp" // fetchPosterAsync / fetchHqArtAsync: the shared art cache #include "theme.hpp" +#include "workers.hpp" + +#include // TextureCache +#include #include @@ -83,18 +87,75 @@ brls::Box* titleBand(const std::string& title, float w, float h, float fontSize) } } // namespace +namespace +{ +// The texture TextureCache holds for `path`, or 0 -- without the lookup +// counting as a reference (getCache does count one). +int cachedTexture(const std::string& path) +{ + auto& cache = brls::TextureCache::instance(); + int tex = cache.getCache(path); + if (tex > 0) cache.removeCache(tex); + return tex; +} +} // namespace + +void loadImageAsync(brls::Image* img, const std::string& path, std::shared_ptr alive) +{ + if (!img || path.empty()) return; + if (cachedTexture(path) > 0) + { + img->setImageFromFile(path); // a cache hit: no decoding + return; + } + workers::run( + [img, path, alive]() { + int w = 0, h = 0, n = 0; + unsigned char* rgba = stbi_load(path.c_str(), &w, &h, &n, 4); + brls::sync([img, path, alive, rgba, w, h]() { + if (!*alive || !rgba) + { + if (rgba) stbi_image_free(rgba); + return; + } + // Another card may have loaded the same file meanwhile. + if (cachedTexture(path) > 0) + { + stbi_image_free(rgba); + img->setImageFromFile(path); + return; + } + // What Image::setImageFromFile does, minus the decoding: the + // previous cached texture loses this reference, and the new + // one is TextureCache's to free. + if (img->getTexture() > 0 && !img->getFreeTexture()) + brls::TextureCache::instance().removeCache(img->getTexture()); + img->setFreeTexture(false); + int tex = nvgCreateImageRGBA(brls::Application::getNVGContext(), w, h, 0, + rgba); + stbi_image_free(rgba); + if (tex <= 0) return; + img->innerSetImage(tex); + brls::TextureCache::instance().addCache(path, tex); + }); + }, + workers::Priority::Low); +} + void attachArt(brls::Image* img, const std::string& id, const std::string& url, std::shared_ptr alive, bool hq, const std::string& localPath) { if (!localPath.empty()) { - img->setImageFromFile(localPath); + loadImageAsync(img, localPath, alive); return; } if (id.empty() || url.empty()) return; + // The file is decoded off the UI thread: a screen of cards decoded a few + // dozen JPEGs (a w1280 backdrop is 3.6 MB of pixels) in a single frame. auto done = [img, alive](std::string path) { if (!*alive || path.empty()) return; - img->setImageFromFile(path); + loadImageAsync(img, path, alive); }; if (hq) stremio::fetchHqArtAsync(id, url, done); @@ -336,4 +397,11 @@ void setPillActive(brls::Button* b, bool active, bool onImage) } } +bool isOnTop(brls::View* v) +{ + if (!v) return false; + auto stack = brls::Application::getActivitiesStack(); + return !stack.empty() && stack.back() == v->getParentActivity(); +} + } // namespace ui diff --git a/app/widgets.hpp b/app/widgets.hpp index a4f6548..baf3f13 100644 --- a/app/widgets.hpp +++ b/app/widgets.hpp @@ -159,6 +159,10 @@ class ShadeBox : public brls::Box float stop; }; +// `path` into `img`: at once when its texture is cached, otherwise decoded on +// a worker and uploaded here. `alive` false (the view gone) drops it. +void loadImageAsync(brls::Image* img, const std::string& path, std::shared_ptr alive); + // Fills `img` once the artwork for `id` lands (thumbnail size, or full size // with `hq`). `alive` guards a fetch that lands after the view is gone. // `localPath`, when set, is used as is (history keeps posters on the SD card). @@ -210,4 +214,10 @@ brls::Button* actionButton(const std::string& text, bool primary, // chips, filters, the Home / Movies / Shows switch. void setPillActive(brls::Button* b, bool active, bool onImage = false); +// True when `v`'s activity is the top one. A screen with another activity +// (player, dialog, detail screen) above it must not free its children: the +// focus stack keeps a raw pointer to whichever one opened the screen above, +// and hands the focus back to it when that screen closes. +bool isOnTop(brls::View* v); + } // namespace ui diff --git a/engine/bt_peer.c b/engine/bt_peer.c index 3b6f5b3..1aaeb53 100644 --- a/engine/bt_peer.c +++ b/engine/bt_peer.c @@ -7,6 +7,8 @@ #include #include +#include // armGetSystemTick (pctest/compat on PC) + #include #include #include @@ -25,6 +27,8 @@ #define HANDSHAKE_LEN 68 #define META_PIECE_LEN 16384 #define META_CONNECT_SECS 2 +// One peer's whole metadata exchange: 8 MB at worst, usually tens of KB. +#define META_FETCH_SECS 30 #define MAX_META_SIZE (8 * 1024 * 1024) static void set_err(char *err, size_t errlen, const char *msg) { @@ -92,10 +96,16 @@ static int connect_timeout(int sock, const struct sockaddr *sa, socklen_t salen, // Returns 0 on success, -1 on error/EOF, and -2 on a clean idle timeout — the // socket's SO_RCVTIMEO fired at a message boundary with nothing read yet. -2 // lets the caller poll for shutdown without dropping a slow-but-alive peer. +static bool conn_expired(const peer_conn *p) { + if (p->stop && *p->stop) return true; + return p->deadline && armGetSystemTick() > p->deadline; +} + static int read_full(peer_conn *p, void *buf, size_t len) { uint8_t *b = buf; size_t got = 0; while (got < len) { + if (conn_expired(p)) return -1; if (p->utp) { int n = utp_bridge_read(p->utp, b + got, (int)(len - got), UTP_IO_TIMEOUT_MS); @@ -207,7 +217,7 @@ static int send_ext(peer_conn *p, uint8_t sub_id, const char *bencode, size_t bl int peer_fetch_metadata(peer_addr addr, const uint8_t info_hash[20], const uint8_t peer_id[20], uint8_t **out, size_t *out_len, - char *err, size_t errlen) { + const volatile bool *stop, char *err, size_t errlen) { int sock = socket(AF_INET, SOCK_STREAM, 0); if (sock < 0) { char e[128]; @@ -242,6 +252,9 @@ int peer_fetch_metadata(peer_addr addr, const uint8_t info_hash[20], // transport helpers (send_ext/recv_msg/read_full/write_full). peer_conn mp = {0}; mp.sock = sock; + mp.stop = stop; + mp.deadline = armGetSystemTick() + + (uint64_t)META_FETCH_SECS * armGetSystemTickFreq(); peer_conn *p = ∓ // Handshake with the extension-protocol bit (reserved byte 5, 0x10) set. @@ -425,6 +438,18 @@ void peer_nb_apply_speed_opts(peer_nb *p) { // Slide any fully-consumed prefix out of the rx buffer. Doing this lazily (only // when we need room or run out of whole messages) is what lets peer_nb_next // hand back a pointer straight into rx instead of copying every payload. +// Doubles the receive buffer (up to PEER_RX_CAP). False when it is already +// there or the allocation fails -- the buffer is then left as it was. +static bool nb_rx_grow(peer_nb *p) { + if (p->rx_cap >= PEER_RX_CAP) return false; + size_t cap = p->rx_cap * 2 > PEER_RX_CAP ? PEER_RX_CAP : p->rx_cap * 2; + uint8_t *r = realloc(p->rx, cap); + if (!r) return false; + p->rx = r; + p->rx_cap = cap; + return true; +} + static void nb_compact(peer_nb *p) { if (p->rx_off == 0) return; if (p->rx_len > p->rx_off) @@ -440,7 +465,8 @@ int peer_nb_init(peer_nb *p, int sock, int64_t piece_count) { p->choked = true; p->bitfield_len = (size_t)((piece_count + 7) / 8); p->tx_cap = 4096; - p->rx = malloc(PEER_RX_CAP); + p->rx_cap = PEER_RX_INIT; + p->rx = malloc(p->rx_cap); p->tx = malloc(p->tx_cap); p->bitfield = calloc(1, p->bitfield_len ? p->bitfield_len : 1); if (!p->rx || !p->tx || !p->bitfield) { @@ -465,7 +491,8 @@ int peer_nb_init_utp(peer_nb *p, void *utp, int64_t piece_count) { p->choked = true; p->bitfield_len = (size_t)((piece_count + 7) / 8); p->tx_cap = 4096; - p->rx = malloc(PEER_RX_CAP); + p->rx_cap = PEER_RX_INIT; + p->rx = malloc(p->rx_cap); p->tx = malloc(p->tx_cap); p->bitfield = calloc(1, p->bitfield_len ? p->bitfield_len : 1); if (!p->rx || !p->tx || !p->bitfield) { @@ -502,26 +529,28 @@ ssize_t peer_nb_pump_rx(peer_nb *p) { ssize_t total = 0; p->rx_more = false; for (;;) { - if (p->rx_len >= PEER_RX_CAP) { + if (p->rx_len >= p->rx_cap) { nb_compact(p); - if (p->rx_len >= PEER_RX_CAP) { + // Still full: a message longer than the buffer (a big bitfield) + // needs room to complete; otherwise the caller drains first. + if (p->rx_len >= p->rx_cap && !nb_rx_grow(p)) { p->rx_more = true; return total; // a whole message is buffered; caller must drain it } } ssize_t n; size_t old_len = p->rx_len; - size_t want = PEER_RX_CAP - p->rx_len; + size_t want = p->rx_cap - p->rx_len; if (p->utp) { n = utp_nb_read((utp_nb_sess *)p->utp, - p->rx + p->rx_len, (int)(PEER_RX_CAP - p->rx_len)); + p->rx + p->rx_len, (int)(p->rx_cap - p->rx_len)); if (n < 0) { if (p->mse_want && !p->mse_active) p->mse_failed = true; return -1; } if (n == 0) return total; } else { - n = recv(p->sock, p->rx + p->rx_len, PEER_RX_CAP - p->rx_len, 0); + n = recv(p->sock, p->rx + p->rx_len, p->rx_cap - p->rx_len, 0); if (n > 0) { // fall through } else if (n == 0) { diff --git a/engine/bt_peer.h b/engine/bt_peer.h index 267c853..f209c91 100644 --- a/engine/bt_peer.h +++ b/engine/bt_peer.h @@ -42,6 +42,12 @@ typedef struct { bool choked; // peer is choking us -> we cannot request uint8_t *bitfield; // which pieces the peer has; NULL if not received yet size_t bitfield_len; + // Optional limits on the blocking reads (zero = none): every read fails + // once *stop turns true or the system tick passes `deadline`. A peer that + // trickles keep-alives or a byte every few seconds never trips the + // socket's own timeout. + const volatile bool *stop; + uint64_t deadline; } peer_conn; // --------------------------------------------------------------------------- @@ -56,9 +62,13 @@ typedef struct { // Framed reads need to survive short reads, so bytes accumulate here until a // whole message is present. Sized well past the largest message (4-byte length // prefix + one 16 KiB block): every recv() on Switch is a bsd IPC, so a small -// buffer turns a fast peer's socket drain into a storm of small IPCs. 64 KiB -// cuts the per-session recv IPC count ~4x versus one-message buffering; at 48 -// sessions that is 6 MB total. +// buffer turns a fast peer's socket drain into a storm of small IPCs. +// A session starts at PEER_RX_INIT and doubles, up to PEER_RX_CAP, each time a +// recv fills what is left of it: a fast peer gets the large buffer (fewer +// IPCs), the slow majority keeps 64 KiB. Every session used to take the full +// 512 KiB, ~20 MB at 40 sessions. PEER_RX_CAP also bounds a message (a +// bitfield of a torrent with millions of pieces). +#define PEER_RX_INIT (64 * 1024) #define PEER_RX_CAP (512 * 1024) typedef struct { @@ -66,6 +76,7 @@ typedef struct { void *utp; // non-blocking µTP session, or NULL for TCP uint8_t *rx; // accumulated inbound bytes (partial messages included) + size_t rx_cap; // its size: PEER_RX_INIT .. PEER_RX_CAP size_t rx_off; // bytes at the front already handed out to the caller size_t rx_len; // total bytes held, rx_off included bool rx_more; // last pump stopped on a full buffer: more may be waiting @@ -166,8 +177,11 @@ int peer_recv(peer_conn *p, uint8_t *payload, uint32_t bufcap, uint32_t *plen); // On success returns 0 and sets *out to a malloc'd buffer of *out_len bytes // whose SHA-1 equals info_hash (caller frees). This is how magnet links obtain // what a .torrent file would have contained. +// +// Gives up when *stop turns true (may be NULL) or after META_FETCH_SECS with +// this one peer, whichever comes first. int peer_fetch_metadata(peer_addr addr, const uint8_t info_hash[20], const uint8_t peer_id[20], uint8_t **out, size_t *out_len, - char *err, size_t errlen); + const volatile bool *stop, char *err, size_t errlen); #endif diff --git a/engine/dhtclient.c b/engine/dhtclient.c index faa9ec7..bb0cdf1 100644 --- a/engine/dhtclient.c +++ b/engine/dhtclient.c @@ -198,7 +198,9 @@ static const char *BOOTSTRAP[] = { }; #define BOOTSTRAP_PORT "6881" -#define DHT_MAX_TARGETS 4 +// Two engines can hold the same hash for a moment (the next episode of a +// pack opens before the previous one closes), beside a magnet lookup or two. +#define DHT_MAX_TARGETS 8 typedef struct { bool active; @@ -371,30 +373,9 @@ static void bg_on_event(void *closure, int event, (void)closure; if (event != DHT_EVENT_VALUES || !info_hash || data_len < 6) return; - dht_target *target = NULL; - mutexLock(&s_bg_mtx); - for (int i = 0; i < DHT_MAX_TARGETS; i++) { - if (s_targets[i].active && - memcmp(s_targets[i].info_hash, info_hash, 20) == 0) { - target = &s_targets[i]; - break; - } - } - dht_peer_cb cb = target ? target->cb : NULL; - void *ctx = target ? target->ctx : NULL; - if (cb) s_cb_inflight++; // dht_background_remove waits for this to drain - mutexUnlock(&s_bg_mtx); - if (!cb) return; - - int n = (int)(data_len / 6); - if (n <= 0) { - mutexLock(&s_bg_mtx); - s_cb_inflight--; - mutexUnlock(&s_bg_mtx); - return; - } peer_addr peers[128]; int np = 0; + int n = (int)(data_len / 6); const uint8_t *b = data; for (int i = 0; i < n && np < 128; i++) { const uint8_t *e = b + i * 6; @@ -402,13 +383,33 @@ static void bg_on_event(void *closure, int event, peers[np].port = (uint16_t)((e[4] << 8) | e[5]); // host byte order if (peers[np].port) np++; } - if (np > 0) { - mutexLock(&s_bg_mtx); - if (target->active) target->delivered += np; + if (np <= 0) return; + + // Every target holding this hash gets them (an engine and a magnet + // lookup, or two engines across an episode switch) -- not just the + // first, which left the other without DHT peers. + dht_peer_cb cbs[DHT_MAX_TARGETS]; + void *ctxs[DHT_MAX_TARGETS]; + int ncb = 0; + mutexLock(&s_bg_mtx); + for (int i = 0; i < DHT_MAX_TARGETS; i++) { + dht_target *tg = &s_targets[i]; + if (!tg->active || !tg->cb || memcmp(tg->info_hash, info_hash, 20) != 0) + continue; + tg->delivered += np; + cbs[ncb] = tg->cb; + ctxs[ncb] = tg->ctx; + ncb++; + } + if (ncb > 0) { s_last_peers_found += np; - mutexUnlock(&s_bg_mtx); - cb(ctx, peers, np); + s_cb_inflight++; // the removers wait for this to drain } + mutexUnlock(&s_bg_mtx); + if (ncb == 0) return; + + for (int i = 0; i < ncb; i++) cbs[i](ctxs[i], peers, np); + mutexLock(&s_bg_mtx); s_cb_inflight--; mutexUnlock(&s_bg_mtx); @@ -600,16 +601,29 @@ static void dht_bg_main(void *arg) { // dial us back (needs the listener/UPnP forwarding). int my_port = torrent_announce_port(); u64 search_iv = (u64)(s_bg_hungry ? 5 : 15) * freq; + // Pick the due hashes (once each) under the lock, search without it: jech's + // dht_search answers from local storage synchronously, through + // bg_on_event, which takes s_bg_mtx itself (not recursive -- any + // node announcing our hash to us used to wedge this thread, and + // the netloop and torrentfs_close behind it). + unsigned char due[DHT_MAX_TARGETS][20]; + int ndue = 0; mutexLock(&s_bg_mtx); for (int i = 0; i < DHT_MAX_TARGETS; i++) { dht_target *tg = &s_targets[i]; if (!tg->active) continue; if (now - tg->last_search > search_iv) { tg->last_search = now; - dht_search(tg->info_hash, my_port, AF_INET, bg_on_event, tg); + bool dup = false; + for (int k = 0; k < ndue && !dup; k++) + dup = memcmp(due[k], tg->info_hash, 20) == 0; + if (!dup) memcpy(due[ndue++], tg->info_hash, 20); } } mutexUnlock(&s_bg_mtx); + // bg_on_event matches by hash and ignores the closure. + for (int i = 0; i < ndue; i++) + dht_search(due[i], my_port, AF_INET, bg_on_event, NULL); // Random-id walk: fills buckets across the whole id space. // anacrolix refreshes buckets until they are full; a sparse @@ -820,6 +834,13 @@ int dht_find_peers(const uint8_t info_hash[20], int target_peers, int budget_ms, tg->active = false; tg->cb = NULL; tg->ctx = NULL; + // As in dht_background_remove: a callback that already copied cb/ctx + // runs outside the lock, and the caller frees ctx when we return. + while (s_cb_inflight > 0) { + mutexUnlock(&s_bg_mtx); + svcSleepThread(1000000ULL); // 1 ms + mutexLock(&s_bg_mtx); + } mutexUnlock(&s_bg_mtx); dlog("DHT fin: %d peers", delivered); @@ -837,12 +858,14 @@ void dht_background_add(const uint8_t info_hash[20], dht_peer_cb cb, void *ctx) { dht_bg_start(); + // Keyed by (hash, ctx): another owner of the same hash keeps its own + // entry. Taking it over -- as this did -- let that owner's removal end + // ours (the previous episode's engine closing under the next one's). mutexLock(&s_bg_mtx); for (int i = 0; i < DHT_MAX_TARGETS; i++) { - if (s_targets[i].active && + if (s_targets[i].active && s_targets[i].ctx == ctx && memcmp(s_targets[i].info_hash, info_hash, 20) == 0) { s_targets[i].cb = cb; - s_targets[i].ctx = ctx; mutexUnlock(&s_bg_mtx); return; } @@ -862,11 +885,11 @@ void dht_background_add(const uint8_t info_hash[20], mutexUnlock(&s_bg_mtx); } -void dht_background_remove(const uint8_t info_hash[20]) { +void dht_background_remove(const uint8_t info_hash[20], void *ctx) { if (!s_bg_init) return; mutexLock(&s_bg_mtx); for (int i = 0; i < DHT_MAX_TARGETS; i++) { - if (s_targets[i].active && + if (s_targets[i].active && s_targets[i].ctx == ctx && memcmp(s_targets[i].info_hash, info_hash, 20) == 0) { s_targets[i].active = false; s_targets[i].cb = NULL; diff --git a/engine/dhtclient.h b/engine/dhtclient.h index a4f9439..446d0dc 100644 --- a/engine/dhtclient.h +++ b/engine/dhtclient.h @@ -38,7 +38,9 @@ void dhtclient_get_last_lookup(int *peers_found, int *good_nodes, int *dubious_n // background process itself keeps running across add/remove (one warm // routing table for the whole engine session) and stops with dht_stop(). void dht_background_add(const uint8_t info_hash[20], dht_peer_cb cb, void *ctx); -void dht_background_remove(const uint8_t info_hash[20]); +// Removes the target `dht_background_add` registered with this hash AND ctx; +// another owner of the same hash keeps receiving. +void dht_background_remove(const uint8_t info_hash[20], void *ctx); // Initialise the shared background DHT's serialisation mutex. Call once at // engine start (single-threaded), before any lookup can touch it; on libnx a diff --git a/engine/httpsrc.c b/engine/httpsrc.c index f30c07b..60c5af9 100644 --- a/engine/httpsrc.c +++ b/engine/httpsrc.c @@ -27,6 +27,7 @@ struct httpsrc { char *direct; // where the redirects led (NULL = same as url) int64_t size; volatile bool cancelled; + const volatile bool *abort; // the owner's flag (httpsrc_open_abortable) uint8_t *buf; // the buffered chunk int64_t buf_pos; // its offset in the resource @@ -70,10 +71,14 @@ static size_t header_cb(char *p, size_t sz, size_t nm, void *ud) { return n; } +static bool is_cancelled(const httpsrc *s) { + return s->cancelled || (s->abort && *s->abort); +} + static int progress_cb(void *ud, curl_off_t dt, curl_off_t dn, curl_off_t ut, curl_off_t un) { (void)dt; (void)dn; (void)ut; (void)un; - return ((httpsrc *)ud)->cancelled ? 1 : 0; + return is_cancelled((httpsrc *)ud) ? 1 : 0; } static void setup(httpsrc *s) { @@ -120,7 +125,7 @@ static long fetch(httpsrc *s, const char *url, int64_t pos, int64_t len) { long code = 0; curl_easy_getinfo(s->curl, CURLINFO_RESPONSE_CODE, &code); - if (s->cancelled) { snprintf(s->err, sizeof(s->err), "cancelled"); return -1; } + if (is_cancelled(s)) { snprintf(s->err, sizeof(s->err), "cancelled"); return -1; } // The write error we caused ourselves on an over-long body is not one. if (rc == CURLE_WRITE_ERROR && s->got == s->want) rc = CURLE_OK; if (rc != CURLE_OK) { @@ -139,8 +144,14 @@ static long fetch(httpsrc *s, const char *url, int64_t pos, int64_t len) { } httpsrc *httpsrc_open(const char *url, char *err, size_t errlen) { + return httpsrc_open_abortable(url, NULL, err, errlen); +} + +httpsrc *httpsrc_open_abortable(const char *url, const volatile bool *abort, + char *err, size_t errlen) { httpsrc *s = calloc(1, sizeof(*s)); if (!s) { set_err(err, errlen, "out of memory"); return NULL; } + s->abort = abort; s->url = strdup(url); s->buf = malloc(CHUNK); s->curl = curl_easy_init(); @@ -181,14 +192,14 @@ httpsrc *httpsrc_open(const char *url, char *err, size_t errlen) { int64_t httpsrc_size(const httpsrc *s) { return s->size; } int64_t httpsrc_read(httpsrc *s, int64_t pos, void *out, int64_t n) { - if (s->cancelled) return -1; + if (is_cancelled(s)) return -1; if (pos < 0) return -1; if (pos >= s->size) return 0; if (!(pos >= s->buf_pos && pos < s->buf_pos + s->buf_len)) { int64_t len = s->size - pos < CHUNK ? s->size - pos : CHUNK; long code = -1; - for (int attempt = 0; attempt < RETRIES && !s->cancelled; attempt++) { + for (int attempt = 0; attempt < RETRIES && !is_cancelled(s); attempt++) { // After a failure, go back through the original URL: a CDN link // from the redirect may have expired. const char *u = (s->direct && attempt == 0) ? s->direct : s->url; diff --git a/engine/httpsrc.h b/engine/httpsrc.h index 0cef250..7b606ad 100644 --- a/engine/httpsrc.h +++ b/engine/httpsrc.h @@ -29,6 +29,12 @@ void httpsrc_set_proxy(const char *proxy); // Opens `url` (http:// or https://): one request to learn the size and that // the server honours ranges. NULL on failure, with a reason in err. httpsrc *httpsrc_open(const char *url, char *err, size_t errlen); +// The same, abandoned (NULL, "cancelled") as soon as *abort turns true -- +// mpv has no cancel_fn for a stream until its open returns, and the open is +// a connect plus a first chunk, up to tens of seconds on a bad link. *abort +// is also polled by every later read, so it must outlive the httpsrc. +httpsrc *httpsrc_open_abortable(const char *url, const volatile bool *abort, + char *err, size_t errlen); // Total size in bytes. int64_t httpsrc_size(const httpsrc *s); diff --git a/engine/listen.c b/engine/listen.c index 69e12c1..3d0744a 100644 --- a/engine/listen.c +++ b/engine/listen.c @@ -256,11 +256,11 @@ int torrent_listener_start(int port, listen_route_fn find) { void torrent_listener_stop(void) { if (!s_thread_started) return; + // The flag only: the thread polls with a 250 ms timeout, sees it and + // closes the socket itself. Closing it from here raced the thread's own + // accept()/close() of the same descriptor -- a number another thread may + // already have been handed again. s_stop = true; - if (s_listen_fd >= 0) { - close(s_listen_fd); // wakes the poll() with POLLNVAL - s_listen_fd = -1; - } threadWaitForExit(&s_thread); threadClose(&s_thread); s_thread_started = false; diff --git a/engine/mse_peer.c b/engine/mse_peer.c index 6279852..b5ac696 100644 --- a/engine/mse_peer.c +++ b/engine/mse_peer.c @@ -212,25 +212,18 @@ int mse_peer_feed(mse_peer *m, const uint8_t *in, size_t in_len, } if (m->phase == 1) { - // Resync on the encrypted VC: the VC is 8 zero bytes under keyB, so - // its ciphertext is a fixed 8-byte keystream prefix. A responder with - // padB == 0 puts it at offset 0; with padB > 0 the offset shifts by - // padB_len, so we scan every plausible offset and verify each by - // decrypting the candidate with a COPY of the RC4 state (the live - // stream only advances on a confirmed match). The scan must keep the - // trailing 7 bytes buffered: a VC split across two feeds would - // otherwise be missed. + // Resync on the encrypted VC. padB is sent in the clear, and B's RC4 + // stream starts at the VC itself, so the VC's ciphertext is always + // the first 8 keystream bytes (m->vc) -- at whatever offset padB's + // length puts it. Only the VC's 8 bytes advance the live stream. + // (The scan used to expect keystream[pos..pos+8) at offset pos and + // skip pos + 8: right only for padB == 0, so nearly every encrypted + // handshake failed.) The trailing 7 bytes stay buffered: a VC split + // across two feeds would otherwise be missed. size_t pos = 0; - size_t limit = in_len >= 8 ? in_len - 8 : 0; - if (limit > 512) limit = 512; // padB is at most 512 bytes - uint8_t zeros[8] = {0}; - while (pos <= limit) { - mse_rc4 probe = m->recv_rc4; - mse_rc4_skip(&probe, pos); - uint8_t expect[8]; - mse_rc4_crypt(&probe, zeros, expect, 8); - if (memcmp(in + pos, expect, 8) == 0) { - mse_rc4_skip(&m->recv_rc4, pos + 8); // padB + VC + while (pos + 8 <= in_len) { + if (memcmp(in + pos, m->vc, 8) == 0) { + mse_rc4_skip(&m->recv_rc4, 8); m->sel_have = 0; m->pad_skip = 0; m->phase = 2; @@ -238,8 +231,11 @@ int mse_peer_feed(mse_peer *m, const uint8_t *in, size_t in_len, return MSE_CONT; } pos++; + if (m->vc_scanned + pos > 512) return MSE_FAIL; // padB is <= 512 } - *consumed = in_len > 7 ? in_len - 7 : 0; + size_t keep = in_len > 7 ? in_len - 7 : 0; + m->vc_scanned += keep; + *consumed = keep; return MSE_CONT; } diff --git a/engine/mse_peer.h b/engine/mse_peer.h index 0f91043..4f75467 100644 --- a/engine/mse_peer.h +++ b/engine/mse_peer.h @@ -63,6 +63,7 @@ typedef struct { mse_rc4 send_rc4; // keyA: our outbound stream mse_rc4 recv_rc4; // keyB: our inbound stream uint8_t vc[8]; // first 8 bytes of the recv keystream (encrypted VC) + size_t vc_scanned; // padB bytes passed over looking for it (<= 512) uint8_t selbuf[6]; // crypto_select(4) + padD_len(2), decrypted size_t sel_have; diff --git a/engine/stream.c b/engine/stream.c index dd8bf3d..80d72ff 100644 --- a/engine/stream.c +++ b/engine/stream.c @@ -13,11 +13,13 @@ typedef struct { torrentfs *tfs; int64_t pos; + volatile bool cancelled; // cancel_cb: this stream only } stream_ctx; static int64_t read_cb(void *cookie, char *buf, uint64_t nbytes) { stream_ctx *s = cookie; - int64_t n = torrentfs_read(s->tfs, s->pos, buf, (int64_t)nbytes); + int64_t n = torrentfs_read_cancel(s->tfs, s->pos, buf, (int64_t)nbytes, + &s->cancelled); if (n > 0) s->pos += n; return n; // 0 = EOF, -1 = error/cancel } @@ -39,6 +41,13 @@ static void close_cb(void *cookie) { free(cookie); // the torrentfs itself is owned by main, not the stream } +// mpv aborting this stream (stop, quit): a read parked on a missing piece +// returns instead of waiting for it. Per stream, not torrentfs_cancel_reader, +// which would fail the next stream's reads too. +static void cancel_cb(void *cookie) { + ((stream_ctx *)cookie)->cancelled = true; +} + static int open_cb(void *user_data, char *uri, mpv_stream_cb_info *info) { (void)uri; stream_ctx *s = calloc(1, sizeof(*s)); @@ -51,6 +60,7 @@ static int open_cb(void *user_data, char *uri, mpv_stream_cb_info *info) { info->seek_fn = seek_cb; info->size_fn = size_cb; info->close_fn = close_cb; + info->cancel_fn = cancel_cb; return 0; } @@ -97,7 +107,8 @@ static void http_cancel_cb(void *cookie) { } static int http_open_cb(void *user_data, char *uri, mpv_stream_cb_info *info) { - (void)user_data; + const volatile bool *abort = user_data; + stream_http_err[0] = 0; // a previous open's reason is not this one's // "nxhttps://host/path" -> "https://host/path" static const char prefix[] = "nxhttps://"; if (strncmp(uri, prefix, sizeof(prefix) - 1) != 0) return MPV_ERROR_LOADING_FAILED; @@ -107,7 +118,7 @@ static int http_open_cb(void *user_data, char *uri, mpv_stream_cb_info *info) { snprintf(url, rest + sizeof("https://"), "https://%s", uri + sizeof(prefix) - 1); char err[160] = {0}; - httpsrc *src = httpsrc_open(url, err, sizeof(err)); + httpsrc *src = httpsrc_open_abortable(url, abort, err, sizeof(err)); free(url); if (!src) { snprintf(stream_http_err, sizeof(stream_http_err), "%s", err); @@ -128,7 +139,7 @@ static int http_open_cb(void *user_data, char *uri, mpv_stream_cb_info *info) { char stream_http_err[160]; -int stream_register_http(mpv_handle *mpv) { +int stream_register_http(mpv_handle *mpv, const volatile bool *abort) { stream_http_err[0] = 0; - return mpv_stream_cb_add_ro(mpv, "nxhttps", NULL, http_open_cb); + return mpv_stream_cb_add_ro(mpv, "nxhttps", (void *)abort, http_open_cb); } diff --git a/engine/stream.h b/engine/stream.h index bb0d036..af76fc9 100644 --- a/engine/stream.h +++ b/engine/stream.h @@ -16,7 +16,10 @@ int stream_register(mpv_handle *mpv, torrentfs *tfs); // https://host/path, read through curl with range requests (httpsrc.c). This // FFmpeg build has no https protocol, so mpv cannot open such a URL itself; // plain http:// (and HLS over it) needs no help. -int stream_register_http(mpv_handle *mpv); +// `abort` (may be NULL) abandons an open in progress when it turns true: set +// it before mpv_terminate_destroy, which otherwise waits that open out. It +// must outlive the mpv instance. +int stream_register_http(mpv_handle *mpv, const volatile bool *abort); // Why the last nxhttps:// open failed ("" if it did not), for the player to // show: mpv itself only reports "loading failed". diff --git a/engine/torrent_meta.c b/engine/torrent_meta.c index 72de7bc..7bc6584 100644 --- a/engine/torrent_meta.c +++ b/engine/torrent_meta.c @@ -592,8 +592,10 @@ static void pipe_worker(void *arg) { uint8_t *md = NULL; size_t len = 0; char e[128]; + // &mp->stop: the deadline, a cancel or another worker's success end + // this fetch too, not just the next one. if (peer_fetch_metadata(pa, mp->info_hash, mp->peer_id, &md, &len, - e, sizeof(e)) != 0) { + &mp->stop, e, sizeof(e)) != 0) { // A peer that answered the SYN but did not yield the metadata is // reachable -- worth separating from the dead majority so the panel // can say "peers connect but refuse" vs "nothing is reachable". @@ -621,6 +623,10 @@ static void pipe_worker(void *arg) { mutexUnlock(&mp->lock); } +static volatile bool s_abort_all = false; + +void torrent_meta_abort_all(void) { s_abort_all = true; } + // Runs the pipeline. Returns the metadata (caller frees) or NULL; the peers // it collected stay in mp->peers / mp->n. static uint8_t *meta_pipe_run(meta_pipe *mp, const volatile bool *cancel) { @@ -633,13 +639,15 @@ static uint8_t *meta_pipe_run(meta_pipe *mp, const volatile bool *cancel) { mp->producers = 2; mutexUnlock(&mp->lock); void (*prod_fn[2])(void *) = { pipe_announce_main, pipe_dht_main }; + // threadClose only on a thread that was created: after a failed + // threadCreate the Thread is uninitialised stack memory. for (int i = 0; i < 2; i++) { - if (threadCreate(&prod[i], prod_fn[i], mp, NULL, 0x10000, - TSNX_THREAD_ARGS(TSNX_CORE_NET, TSNX_PRIO_NET_BG)) == 0 && - threadStart(&prod[i]) == 0) { + bool created = threadCreate(&prod[i], prod_fn[i], mp, NULL, 0x10000, + TSNX_THREAD_ARGS(TSNX_CORE_NET, TSNX_PRIO_NET_BG)) == 0; + if (created && threadStart(&prod[i]) == 0) { prod_started[i] = true; } else { - threadClose(&prod[i]); + if (created) threadClose(&prod[i]); pipe_producer_done(mp); } } @@ -655,12 +663,12 @@ static uint8_t *meta_pipe_run(meta_pipe *mp, const volatile bool *cancel) { mp->workers = META_WORKERS; mutexUnlock(&mp->lock); for (int i = 0; i < META_WORKERS; i++) { - if (threadCreate(&work[nwork], pipe_worker, mp, NULL, 0x10000, - TSNX_THREAD_ARGS(TSNX_CORE_NET, TSNX_PRIO_NET_BG)) == 0 && - threadStart(&work[nwork]) == 0) { + bool created = threadCreate(&work[nwork], pipe_worker, mp, NULL, 0x10000, + TSNX_THREAD_ARGS(TSNX_CORE_NET, TSNX_PRIO_NET_BG)) == 0; + if (created && threadStart(&work[nwork]) == 0) { nwork++; } else { - threadClose(&work[nwork]); + if (created) threadClose(&work[nwork]); mutexLock(&mp->lock); mp->workers--; mutexUnlock(&mp->lock); @@ -680,7 +688,7 @@ static uint8_t *meta_pipe_run(meta_pipe *mp, const volatile bool *cancel) { int w = mp->workers; mutexUnlock(&mp->lock); if (w <= 0) break; - if ((cancel && *cancel) || + if ((cancel && *cancel) || s_abort_all || armGetSystemTick() - t0 > (u64)META_DEADLINE_SECS * freq) mp->stop = true; svcSleepThread(50000000ULL); @@ -977,7 +985,10 @@ typedef struct { static size_t curl_write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) { membuf *m = userdata; size_t add = size * nmemb; - if (m->len + add > 4 * 1024 * 1024) return 0; // sanity cap + // An announce reply is a few KB (200 peers, even non-compact, ~15 KB). + // The cap also bounds the bencode tree a hostile tracker can make us + // build (TLS to trackers is unverified): 4 MB parsed to ~80 MB of nodes. + if (m->len + add > 512 * 1024) return 0; char *grown = realloc(m->data, m->len + add + 1); if (!grown) return 0; m->data = grown; diff --git a/engine/torrent_meta.h b/engine/torrent_meta.h index 42ee1cf..b3b9d96 100644 --- a/engine/torrent_meta.h +++ b/engine/torrent_meta.h @@ -127,6 +127,11 @@ int torrent_load_magnet_peers(torrent_meta *t, const char *magnet_uri, peer_addr *out, int max, int *out_n, char *err, size_t errlen); +// Ends every magnet metadata fetch in progress, and any started after, as if +// cancelled: for app exit, where a fetch still running holds up the join of +// whatever thread it runs on (up to its 75 s deadline). +void torrent_meta_abort_all(void); + // Cancellable variant of torrent_load_magnet_peers. int torrent_load_magnet_peers_cancel(torrent_meta *t, const char *magnet_uri, peer_addr *out, int max, int *out_n, diff --git a/engine/torrentfs.c b/engine/torrentfs.c index adfe278..0799d88 100644 --- a/engine/torrentfs.c +++ b/engine/torrentfs.c @@ -461,6 +461,7 @@ struct torrentfs { // FAT32-chunked cache (see v2 for the append-only rationale). int chunks[CACHE_MAX_CHUNKS]; char cache_base[192]; + int cache_slot; // cache_slot_take(); -1 = untracked name Mutex cache_lock; int32_t *piece_slot; // piece index -> cache slot, -1 = not stored int64_t next_slot; @@ -779,6 +780,12 @@ static bool cache_piece_read(torrentfs *t, int64_t idx, int64_t within, memcpy(buf, src + within, len); return true; } + // A slot is assigned before the piece is written and kept if the write + // fails (the re-download reuses it), so the slot alone does not mean the + // bytes are there: cache_read_upto runs on into the next pieces and would + // hand mpv whatever FAT left in that gap. + if (__atomic_load_n(&t->status[idx], __ATOMIC_ACQUIRE) != PIECE_DONE) + return false; int ci; int64_t co; if (!piece_loc(t, idx, false, &ci, &co)) return false; @@ -787,6 +794,32 @@ static bool cache_piece_read(torrentfs *t, int64_t idx, int64_t within, return fd_xfer(f, co + within, buf, len, false); } +// Two engines can be alive at once (the next episode's player opens before +// the previous one's destructor closes its engine; an abandoned magnet open +// finishes in the background). Each open deletes and rewrites its chunks, so +// they must not share a name: every live engine holds a slot of its own. +// Slot 0 keeps the plain name, so the usual single engine reuses (and cleans) +// the same files as before. +#define CACHE_SLOTS 8 +static Mutex s_cache_slot_mtx; +static uint32_t s_cache_slots; + +static int cache_slot_take(void) { + mutexLock(&s_cache_slot_mtx); + int k = -1; + for (int i = 0; i < CACHE_SLOTS; i++) + if (!(s_cache_slots & (1u << i))) { k = i; s_cache_slots |= 1u << i; break; } + mutexUnlock(&s_cache_slot_mtx); + return k; +} + +static void cache_slot_give(int k) { + if (k < 0) return; + mutexLock(&s_cache_slot_mtx); + s_cache_slots &= ~(1u << k); + mutexUnlock(&s_cache_slot_mtx); +} + static void cache_delete_all(torrentfs *t) { for (int i = 0; i < CACHE_MAX_CHUNKS; i++) { char path[256]; @@ -903,6 +936,10 @@ static size_t cache_read_upto(torrentfs *t, int64_t off, void *buf, size_t len) // slot's 4-bit generation, so a slot reused by another address is not blamed. #define PEER_FROM_SLOT_MASK 0x7FF static inline int16_t peer_from_tag(const torrentfs *t, int pidx) { + // An incoming session has no pool slot (pidx -1): nothing to blame. The + // tag used to read peer_gen[-1] and decode as slot 2047, banning whoever + // sat there. + if (pidx < 0 || pidx >= TFS_MAX_PEERS) return -1; return (int16_t)((pidx & PEER_FROM_SLOT_MASK) | ((t->peer_gen[pidx] & 0xF) << 11)); } @@ -1203,6 +1240,10 @@ static void add_peers_src(torrentfs *t, const peer_addr *peers, int n, int src) t->peer_src[slot] = (uint8_t)src; t->peer_best_kbps[slot] = 0.0f; t->peer_rot[slot] = 0; + // Per-address flags too: a new address must not inherit the evicted + // one's "TCP failed, try uTP" or "plaintext only". + t->peer_utp_retry[slot] = 0; + t->peer_mse_off[slot] = 0; added++; } mutexUnlock(&t->lock); @@ -1651,16 +1692,16 @@ static void sess_close(torrentfs *t, sess *s, bool failed) { // attempt (uTP can traverse NAT where TCP SYN is dropped). uTP sessions // (nb.utp != NULL) never re-flag, so a peer cannot loop. bool was_tcp = (s->nb.utp == NULL); + // peer_nb_free zeroes nb: read what the MSE bookkeeping below needs first. + bool mse_want = s->nb.mse_want; + bool mse_failed = s->nb.mse_failed; + bool mse_active = s->nb.mse_active; if (s->connecting) { - // still a raw socket; peer_nb was never attached - if (s->nb.sock >= 0) close(s->nb.sock); - s->nb.sock = -1; - // a uTP dial in flight also occupies a utp_nb session slot; without - // this the 128-slot table leaks and all further uTP dials fail - if (s->nb.utp) { - utp_nb_close((utp_nb_sess *)s->nb.utp); - s->nb.utp = NULL; - } + // A TCP dial is still a raw socket (no buffers yet); a uTP dial + // already has its rx/tx/bitfield and a utp_nb slot. peer_nb_free + // handles both: closing only the socket leaked ~516 KB per failed + // uTP dial, and the utp_nb table with it. + peer_nb_free(&s->nb); if (t->st_connecting > 0) t->st_connecting--; } else { if (s->nb.handshaked && t->st_live > 0) t->st_live--; @@ -1677,7 +1718,7 @@ static void sess_close(torrentfs *t, sess *s, bool failed) { release_peer(t, s->pidx, failed, had_conn, kind); // A plaintext handshake that actually connected is proof the peer does // not need MSE; remember it so later dials skip the (wasted) attempt. - if (had_conn && !s->nb.mse_want && s->pidx >= 0) { + if (had_conn && !mse_want && s->pidx >= 0) { mutexLock(&t->lock); t->peer_mse_off[s->pidx] = 1; mutexUnlock(&t->lock); @@ -1687,8 +1728,8 @@ static void sess_close(torrentfs *t, sess *s, bool failed) { // retry immediately -- this is a protocol mismatch, not a network // failure, so it must not count into the backoff. A peer that closes // the stream before the MSE handshake finished counts as refused too. - if (failed && !had_conn && s->nb.mse_want && - (s->nb.mse_failed || !s->nb.mse_active)) { + if (failed && !had_conn && mse_want && s->pidx >= 0 && + (mse_failed || !mse_active)) { mutexLock(&t->lock); t->peer_mse_off[s->pidx] = 1; t->peer_fails[s->pidx] = 0; @@ -1884,6 +1925,8 @@ static void sess_connected(torrentfs *t, sess *s, u64 now) { // same torrent: MSE off = 66 KB/s, MSE on = 39 KB/s. Peers that require // encryption decline us; that is their call. Re-enable by setting // mse_want = !peer_mse_off[...] here and in sess_dial_utp_at. + // NB: that A/B ran with a VC resync that only worked for padB == 0 (most + // responders pad), fixed since in mse_peer.c -- worth measuring again. s->nb.mse_want = 0; mutexUnlock(&t->lock); peer_nb_send_handshake(&s->nb, t->meta.info_hash, t->peer_id); @@ -3018,8 +3061,14 @@ static void sess_msg(torrentfs *t, sess *s, int sid, uint8_t id, uint8_t *pl, aq_req_dec(a, b); a->have_cnt++; // A non-strict reader streams partial pieces block by block. - if ((int64_t)idx == t->read_blocked_piece && !t->strict_verify) + // Under the lock: the reader checks piece_ready and then waits + // under it, so a wake between the two is not lost (it slept out + // its 20 ms timeout instead). + if ((int64_t)idx == t->read_blocked_piece && !t->strict_verify) { + mutexLock(&t->lock); condvarWakeAll(&t->piece_cv); + mutexUnlock(&t->lock); + } t->st_blocks_have++; t->last_progress_tick = now; @@ -3638,6 +3687,7 @@ static void rotate_out(torrentfs *t, sess *s, u64 now, const char *why) { engine_log(ENGINE_LOG_INFO, "[rotate] sess %d out (%s): cap=%.0fKB/s slow=%d live=%d", (int)(s - t->S), why, cap / 1024.0, slow ? 1 : 0, t->st_live); s->fail_kind = PEER_FAIL_CHOKE_ROTATE; + bool was_choked = s->nb.choked; // sess_close zeroes nb sess_close(t, s, true); if (pidx >= 0 && slow) { mutexLock(&t->lock); @@ -3646,7 +3696,7 @@ static void rotate_out(torrentfs *t, sess *s, u64 now, const char *why) { mutexUnlock(&t->lock); } t->st_rotated++; - if (s->nb.choked) t->st_rotated_choked++; + if (was_choked) t->st_rotated_choked++; } // Turnover, once per upkeep tick (netloop). @@ -4410,7 +4460,15 @@ torrentfs *torrentfs_open_file_cancel(const char *source, const char *cache_path else aq_ok = false; - snprintf(t->cache_base, sizeof(t->cache_base), "%s", cache_path); + t->cache_slot = cache_slot_take(); + if (t->cache_slot == 0) + snprintf(t->cache_base, sizeof(t->cache_base), "%s", cache_path); + else if (t->cache_slot > 0) + snprintf(t->cache_base, sizeof(t->cache_base), "%s.s%d", cache_path, + t->cache_slot); + else // all slots busy: a unique, untracked name (deleted on close) + snprintf(t->cache_base, sizeof(t->cache_base), "%s.t%llx", cache_path, + (unsigned long long)armGetSystemTick()); tsnx_ensure_parent_dirs(t->cache_base); mutexInit(&t->cache_lock); mutexInit(&t->lock); @@ -4507,7 +4565,7 @@ void torrentfs_close(torrentfs *tfs) { tfs->stop = true; // Stop the background DHT before freeing the torrent context it delivers to. - dht_background_remove(tfs->meta.info_hash); + dht_background_remove(tfs->meta.info_hash, tfs); if (tfs->discovery_started) { threadWaitForExit(&tfs->discovery); @@ -4537,6 +4595,7 @@ void torrentfs_close(torrentfs *tfs) { if (tfs->chunks[i] >= 0) { close(tfs->chunks[i]); tfs->chunks[i] = -1; } if (!tfs->ram_mode) // RAM mode never creates cache files cache_delete_all(tfs); // per-playback scratch, not a library + cache_slot_give(tfs->cache_slot); // the name is free for the next open for (int i = 0; i < AQ_MAX; i++) { free(tfs->aq[i].buf); free(tfs->aq[i].have); @@ -4767,6 +4826,12 @@ bool torrentfs_select_file(torrentfs *tfs, int file_index) { while (__atomic_load_n(&tfs->select_gen, __ATOMIC_ACQUIRE) == gen0 && !tfs->stop) svcSleepThread(2000000ULL); } + } else if (tfs->netloop_started) { + // Stopping: the netloop may still be finishing an iteration (or its + // final sess_close loop) and writing into aq[].buf, which apply would + // free under it. A torrent being closed has no use for the switch. + tfs->cancel_reader = false; + return false; } else { torrentfs_apply_select_file(tfs, file_index); } @@ -4844,6 +4909,13 @@ static bool piece_ready(torrentfs *t, int64_t idx, int b0) { } int64_t torrentfs_read(torrentfs *tfs, int64_t offset, char *buf, int64_t nbytes) { + return torrentfs_read_cancel(tfs, offset, buf, nbytes, NULL); +} + +int64_t torrentfs_read_cancel(torrentfs *tfs, int64_t offset, char *buf, + int64_t nbytes, const volatile bool *cancel) { + int empty_reads = 0; // in a row, of the same piece (see below) +#define READ_CANCELLED() (tfs->stop || tfs->cancel_reader || (cancel && *cancel)) again: tsnx_engine_wd_tick(6); hb_beat(tfs, HB_READER); @@ -4868,11 +4940,11 @@ int64_t torrentfs_read(torrentfs *tfs, int64_t offset, char *buf, int64_t nbytes u64 last_wd = wait_start; int64_t last_recv = tfs->st_bytes_recv; u64 last_recv_tick = wait_start; - while (!tfs->stop && !tfs->cancel_reader && !piece_ready(tfs, first, b0)) { + while (!READ_CANCELLED() && !piece_ready(tfs, first, b0)) { // Woken when the piece verifies (or, non-strict, a block lands); the // 20 ms cap keeps the stall/heartbeat bookkeeping below running. mutexLock(&tfs->lock); - if (!tfs->stop && !tfs->cancel_reader && !piece_ready(tfs, first, b0)) + if (!READ_CANCELLED() && !piece_ready(tfs, first, b0)) condvarWaitTimeout(&tfs->piece_cv, &tfs->lock, 20000000ULL); mutexUnlock(&tfs->lock); u64 now = armGetSystemTick(); @@ -4918,7 +4990,7 @@ int64_t torrentfs_read(torrentfs *tfs, int64_t offset, char *buf, int64_t nbytes } } tfs->read_blocked_piece = -1; - if (tfs->stop || tfs->cancel_reader) return -1; + if (READ_CANCELLED()) return -1; double waited = (double)(armGetSystemTick() - wait_start) / tfs->freq; if (waited > 5.0) { engine_log(ENGINE_LOG_WARN, @@ -4943,13 +5015,32 @@ int64_t torrentfs_read(torrentfs *tfs, int64_t offset, char *buf, int64_t nbytes // The block was there a moment ago (evicted from the RAM window in // between, or a failed hash put it back to NEEDED): wait for it again. // Returning 0 here would be EOF to mpv. - if (!tfs->stop && !tfs->cancel_reader) { + if (!READ_CANCELLED()) { + // SD mode: a DONE piece whose bytes cannot be read back (its chunk + // will not open, a short read) stays DONE forever, and this loop + // with it -- the picture freezes for good. After ~1 s of that, + // fetch it again (the re-download reuses its cache slot). + if (!tfs->ram_mode && ++empty_reads >= 50) { + empty_reads = 0; + mutexLock(&tfs->lock); + if (tfs->status[first] == PIECE_DONE) { + tfs->status[first] = PIECE_NEEDED; + if (tfs->pieces_done > 0) tfs->pieces_done--; + tfs->st_cache_wr_fail++; + set_err(tfs, "cache read failed, piece %lld", (long long)first); + engine_log(ENGINE_LOG_WARN, + "[read] piece %lld unreadable from the SD cache, refetching", + (long long)first); + } + mutexUnlock(&tfs->lock); + } svcSleepThread(20000000ULL); goto again; } return -1; } return (int64_t)got; +#undef READ_CANCELLED } void torrentfs_cancel(torrentfs *tfs) { diff --git a/engine/torrentfs.h b/engine/torrentfs.h index 74d1ef6..ee6b7ae 100644 --- a/engine/torrentfs.h +++ b/engine/torrentfs.h @@ -47,6 +47,10 @@ int64_t torrentfs_size(const torrentfs *tfs); // returns as many contiguously-available bytes as possible (a short read). // Returns bytes read, 0 at EOF, or -1 if cancelled/error. int64_t torrentfs_read(torrentfs *tfs, int64_t offset, char *buf, int64_t nbytes); +// The same, also returning -1 once *cancel turns true (within ~20 ms): one +// reader's cancel, unlike torrentfs_cancel_reader which fails every reader. +int64_t torrentfs_read_cancel(torrentfs *tfs, int64_t offset, char *buf, + int64_t nbytes, const volatile bool *cancel); // Move the download priority window without reading (used by seek). void torrentfs_set_playhead(torrentfs *tfs, int64_t offset); diff --git a/engine/udp_tracker.c b/engine/udp_tracker.c index 98bad36..55d4a4f 100644 --- a/engine/udp_tracker.c +++ b/engine/udp_tracker.c @@ -16,6 +16,29 @@ #include #endif +// xorshift64: plenty for transaction ids, and no shared state. +static uint32_t tx_rand(uint64_t *s) { + uint64_t x = *s ? *s : 0x2545F4914F6CDD1DULL; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + *s = x; + return (uint32_t)(x >> 32); +} + +// A fresh seed per announce: the clock at its finest, salted with a stack +// address (announces run on several threads at once). +static uint64_t tx_seed(const void *salt) { +#ifdef __SWITCH__ + uint64_t t = armGetSystemTick(); +#else + struct timeval tv; + gettimeofday(&tv, NULL); + uint64_t t = (uint64_t)tv.tv_sec * 1000000u + (uint64_t)tv.tv_usec; +#endif + return t ^ ((uint64_t)(uintptr_t)salt << 20) ^ 0x9E3779B97F4A7C15ULL; +} + // BEP 15 magic protocol id used to initiate a connection. #define UDP_PROTOCOL_ID 0x41727101980ULL @@ -150,11 +173,15 @@ int udp_announce(const char *url, const uint8_t info_hash[20], } freeaddrinfo(res); - srand((unsigned)time(NULL) ^ (unsigned)(uintptr_t)&sock); + // Transaction ids from a generator of our own, seeded from the tick + // counter: srand(time) here re-seeded the process-wide rand() on every + // announce -- ids an off-path host could predict (and inject peers + // with), and the DHT's own rand() sequence reset under it. + uint64_t rng = tx_seed(&sock); // --- Step 1: connect, to obtain a short-lived connection_id --- uint8_t creq[16]; - uint32_t ctxid = (uint32_t)rand(); + uint32_t ctxid = tx_rand(&rng); put64(creq, UDP_PROTOCOL_ID); put32(creq + 8, ACTION_CONNECT); put32(creq + 12, ctxid); @@ -172,7 +199,7 @@ int udp_announce(const char *url, const uint8_t info_hash[20], // --- Step 2: announce --- uint8_t areq[98]; - uint32_t atxid = (uint32_t)rand(); + uint32_t atxid = tx_rand(&rng); put64(areq + 0, connection_id); put32(areq + 8, ACTION_ANNOUNCE); put32(areq + 12, atxid); @@ -183,7 +210,7 @@ int udp_announce(const char *url, const uint8_t info_hash[20], put64(areq + 72, 0); // uploaded put32(areq + 80, first ? 2 : 0); // event: started or none put32(areq + 84, 0); // IP (0 = use source) - put32(areq + 88, (uint32_t)rand()); // key + put32(areq + 88, tx_rand(&rng)); // key put32(areq + 92, (uint32_t)max_peers); // num_want areq[96] = (uint8_t)(port >> 8); areq[97] = (uint8_t)(port & 0xff); diff --git a/engine/upnp.c b/engine/upnp.c index 6aecb9c..ebf2d1d 100644 --- a/engine/upnp.c +++ b/engine/upnp.c @@ -25,6 +25,7 @@ #define SSDP_MSEND 3 // M-SEARCH transmissions #define SSDP_WAIT_MS 500 // per transmission, recv timeout #define HTTP_TIMEOUT_SECS 4 +#define UPNP_LEASE_SECS 3600 // renewed every half of it while we run static Thread s_thread; static bool s_started = false; @@ -215,32 +216,26 @@ static char *http_get(const char *url) { return m.data; } -// SOAP AddPortMapping POST; returns 1 on success. -static int soap_add_mapping(const char *control_url, const char *svc_type, - const char *local_ip, int internal_port) { - char body[1024]; +// One SOAP action against the IGD; returns 1 on HTTP 200. `args` is the +// action's argument elements. `abortable`: give up when s_stop is set -- not +// for the DeletePortMapping sent because we are stopping. +static int soap_call(const char *control_url, const char *svc_type, + const char *action, const char *args, long timeout_secs, + bool abortable) { + char body[1536]; snprintf(body, sizeof(body), "" "" - "" - "" - "%d" - "TCP" - "%d" - "%s" - "1" - "TorrentShopNX" - "0" - "", - svc_type, internal_port, internal_port, local_ip); + "%s", + action, svc_type, args, action); membuf m = {0}; CURL *c = curl_easy_init(); if (!c) return 0; char soapaction[192]; - snprintf(soapaction, sizeof(soapaction), "\"%s#AddPortMapping\"", svc_type); + snprintf(soapaction, sizeof(soapaction), "\"%s#%s\"", svc_type, action); struct curl_slist *hdrs = NULL; hdrs = curl_slist_append(hdrs, "Content-Type: text/xml; charset=\"utf-8\""); @@ -254,10 +249,12 @@ static int soap_add_mapping(const char *control_url, const char *svc_type, curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdrs); curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, curl_write_cb); curl_easy_setopt(c, CURLOPT_WRITEDATA, &m); - curl_easy_setopt(c, CURLOPT_TIMEOUT, (long)HTTP_TIMEOUT_SECS); + curl_easy_setopt(c, CURLOPT_TIMEOUT, timeout_secs); curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L); - curl_easy_setopt(c, CURLOPT_NOPROGRESS, 0L); - curl_easy_setopt(c, CURLOPT_XFERINFOFUNCTION, upnp_curl_progress_cb); + if (abortable) { + curl_easy_setopt(c, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(c, CURLOPT_XFERINFOFUNCTION, upnp_curl_progress_cb); + } curl_easy_setopt(c, CURLOPT_SSL_VERIFYPEER, 0L); curl_easy_setopt(c, CURLOPT_SSL_VERIFYHOST, 0L); @@ -272,6 +269,36 @@ static int soap_add_mapping(const char *control_url, const char *svc_type, return ok; } +// AddPortMapping for `port` (TCP, same port both sides) with a lease in +// seconds; 0 = until the router reboots. +static int soap_add_mapping(const char *control_url, const char *svc_type, + const char *local_ip, int port, int lease_secs) { + char args[640]; + snprintf(args, sizeof(args), + "" + "%d" + "TCP" + "%d" + "%s" + "1" + "TorrentShopNX" + "%d", + port, port, local_ip, lease_secs); + return soap_call(control_url, svc_type, "AddPortMapping", args, + HTTP_TIMEOUT_SECS, true); +} + +static int soap_delete_mapping(const char *control_url, const char *svc_type, int port) { + char args[256]; + snprintf(args, sizeof(args), + "" + "%d" + "TCP", + port); + // Short: it runs as the app quits. + return soap_call(control_url, svc_type, "DeletePortMapping", args, 2, false); +} + // Our LAN IP, as seen by the IGD: connect a UDP socket towards the IGD host // (nothing is sent; the route lookup gives us the interface address). static int local_ip_for(const char *location, char *out, size_t outlen) { @@ -400,13 +427,39 @@ static void upnp_main(void *arg) { engine_log(ENGINE_LOG_INFO, "[upnp] AddPortMapping %s:%d TCP -> %s:%d", local_ip, port, local_ip, port); - if (soap_add_mapping(curl_full, svc, local_ip, port)) { - torrent_set_announce_port(port); - engine_log(ENGINE_LOG_INFO, - "[upnp] port %d mapped: peers can dial in now", port); - } else { + // A lease that expires, renewed while we run and deleted when we stop: + // the permanent one (lease 0) this used to ask for stayed open on the + // router, pointing at the console, long after the app was gone. Some + // routers only take permanent leases (error 725): those get one, and + // the delete on the way out. + int lease = UPNP_LEASE_SECS; + int mapped = soap_add_mapping(curl_full, svc, local_ip, port, lease); + if (!mapped && !s_stop) { + lease = 0; + mapped = soap_add_mapping(curl_full, svc, local_ip, port, 0); + } + if (!mapped) { engine_log(ENGINE_LOG_WARN, "[upnp] AddPortMapping failed"); + return; + } + torrent_set_announce_port(port); + engine_log(ENGINE_LOG_INFO, "[upnp] port %d mapped (lease %ds): peers can dial in now", + port, lease); + + // Stay until upnp_stop: renew a finite lease halfway through it. + u64 freq = armGetSystemTickFreq(); + u64 renew_at = armGetSystemTick() + (u64)(lease / 2) * freq; + while (!s_stop) { + svcSleepThread(100000000ULL); // 100 ms slices: stop is prompt + tsnx_engine_wd_tick(3); + if (lease > 0 && armGetSystemTick() >= renew_at) { + if (!soap_add_mapping(curl_full, svc, local_ip, port, lease) && !s_stop) + engine_log(ENGINE_LOG_WARN, "[upnp] lease renewal failed"); + renew_at = armGetSystemTick() + (u64)(lease / 2) * freq; + } } + if (soap_delete_mapping(curl_full, svc, port)) + engine_log(ENGINE_LOG_INFO, "[upnp] port %d mapping removed", port); } int upnp_start(int internal_port) { diff --git a/library/borealis/VENDORED.md b/library/borealis/VENDORED.md index 3ffc123..4932365 100644 --- a/library/borealis/VENDORED.md +++ b/library/borealis/VENDORED.md @@ -150,6 +150,18 @@ narrow column that cut long names short (audio tracks, subtitles). The constructor now sizes the root to `Application::contentWidth` and sets the list's side padding to 8% of it. Marked `NX-torrent-player patch`. +### 9. `library/lib/core/thread.cpp` — exceptions in `brls::async` tasks + +`task_loop` runs each queued task inside `try`/`catch` and logs what it catches. +Upstream lets an exception from a task (a `bad_alloc` parsing a large answer) +reach `std::terminate`, which closes the app; the sync side +(`performSyncTasks`) was already guarded. Marked `LOCAL PATCH`. + +The same file: `task_loop` waits on a condition variable between batches, +woken by `Threading::async()` and `Threading::stop()`, instead of a fixed +`retro_sleep(500)` that delayed every `brls::async` task by up to half a +second. Marked `LOCAL PATCH`. + ## Updating Re-cloning upstream **drops the patch** — re-apply it, and check this file's diff --git a/library/borealis/library/lib/core/thread.cpp b/library/borealis/library/lib/core/thread.cpp index 3a5162b..0878dd9 100644 --- a/library/borealis/library/lib/core/thread.cpp +++ b/library/borealis/library/lib/core/thread.cpp @@ -19,6 +19,8 @@ #include #include #include +#include +#include #ifdef BOREALIS_USE_STD_THREAD #include @@ -70,10 +72,18 @@ void Threading::sync(const std::function& func) m_sync_functions.push_back(func); } +// LOCAL PATCH: task_loop slept a fixed 500 ms between batches, so every +// brls::async task waited up to half a second before it even started. It now +// waits on this, woken by async() and stop(). +static std::condition_variable s_async_cv; + void Threading::async(const std::function& task) { - std::lock_guard guard(m_async_mutex); - m_async_tasks.push_back(task); + { + std::lock_guard guard(m_async_mutex); + m_async_tasks.push_back(task); + } + s_async_cv.notify_one(); } size_t Threading::delay(long milliseconds, const std::function& func) @@ -175,7 +185,11 @@ void Threading::start() void Threading::stop() { - task_loop_active = false; + { + std::lock_guard guard(m_async_mutex); + task_loop_active = false; + } + s_async_cv.notify_all(); #ifdef BOREALIS_USE_STD_THREAD task_loop_thread->join(); @@ -201,10 +215,27 @@ void* Threading::task_loop(void* a) for (auto task : m_tasks_copy) { - task(); + // LOCAL PATCH: an exception out of a task (bad_alloc parsing a + // large answer, say) used to reach std::terminate and kill the + // app. performSyncTasks already guards the sync side this way. + try + { + task(); + } + catch (std::exception& e) + { + brls::Logger::error("error: async task: {}", e.what()); + } + catch (...) + { + brls::Logger::error("error: async task: unknown exception"); + } } - retro_sleep(500); + // LOCAL PATCH: wait for the next task (or stop), not a fixed 500 ms. + std::unique_lock lk(m_async_mutex); + s_async_cv.wait_for(lk, std::chrono::milliseconds(500), + [] { return !m_async_tasks.empty() || !task_loop_active; }); } return NULL; } diff --git a/pctest/test_library.cpp b/pctest/test_library.cpp index 6b9de3e..dc67010 100644 --- a/pctest/test_library.cpp +++ b/pctest/test_library.cpp @@ -6,6 +6,7 @@ #include #include +#include "fsutil.hpp" #include "library.hpp" #define CHECK(cond) \ @@ -161,6 +162,48 @@ static void test_keys() std::printf(" PASSED\n"); } +// fsutil: what config, library and history are saved and loaded through. +static void test_files() +{ + std::printf("[5/5] Saves: atomic, and a cut file falls back to the last good one...\n"); + const std::string p = "fsutil_test.json"; + std::remove(p.c_str()); + std::remove((p + ".tmp").c_str()); + std::remove((p + ".bak").c_str()); + auto complete = [](const std::string& b) { return fsutil::looksComplete(b); }; + + CHECK(fsutil::writeAtomic(p, "{\"v\":1}\n")); + CHECK(fsutil::writeAtomic(p, "{\"v\":2}\n")); + std::string body; + CHECK(fsutil::readFirst(p, body, complete) && body == "{\"v\":2}\n"); + + // The saved file cut short (a write FAT lost): the version before. + if (FILE* f = std::fopen(p.c_str(), "wb")) + { + std::fputs("{\"v\":", f); + std::fclose(f); + } + CHECK(fsutil::readFirst(p, body, complete) && body == "{\"v\":1}\n"); + + // A save cut between its renames: no file, a whole .tmp. + std::remove(p.c_str()); + if (FILE* f = std::fopen((p + ".tmp").c_str(), "wb")) + { + std::fputs("{\"v\":3}", f); + std::fclose(f); + } + CHECK(fsutil::readFirst(p, body, complete) && body == "{\"v\":3}"); + + CHECK(fsutil::looksComplete("{} \r\n")); + CHECK(!fsutil::looksComplete("")); + CHECK(!fsutil::looksComplete("{\"a\":[1,2")); + + std::remove(p.c_str()); + std::remove((p + ".tmp").c_str()); + std::remove((p + ".bak").c_str()); + std::printf(" PASSED\n"); +} + int main() { std::printf("=========================================\n"); @@ -170,6 +213,7 @@ int main() test_next(); test_merge(); test_keys(); + test_files(); std::printf("\nALL TESTS PASSED\n"); return 0; } diff --git a/pctest/test_release.cpp b/pctest/test_release.cpp index e1e956d..085cd47 100644 --- a/pctest/test_release.cpp +++ b/pctest/test_release.cpp @@ -180,6 +180,18 @@ static void test_edges() CHECK(b.season == -1 && b.episode == -1 && b.height == 1080); auto c = release::parse("S1E1"); CHECK(c.season == 1 && c.episode == 1); + // A resolution after a dash is not the end of a range. + auto d = release::parse("Show S01E05 - 720p"); + CHECK(d.season == 1 && d.seasonTo == 1 && d.episode == 5 && d.episodeTo == 5); + auto e = release::parse("Show S02 - 480p WEB-DL"); + CHECK(e.season == 2 && e.seasonTo == 2); + auto f = release::parse("Show S03E10-E08"); // ends before it starts + CHECK(f.episode == 10 && f.episodeTo == 10); + // Real ranges still are. + auto g = release::parse("Show S01E01-E08 1080p"); + CHECK(g.episode == 1 && g.episodeTo == 8); + auto h = release::parse("Show S01-S03 720p"); + CHECK(h.season == 1 && h.seasonTo == 3); std::printf(" PASSED\n"); } diff --git a/pctest/test_suite.c b/pctest/test_suite.c index df0b20a..3eb4fcb 100644 --- a/pctest/test_suite.c +++ b/pctest/test_suite.c @@ -23,6 +23,7 @@ #include #include +#include #include #include @@ -201,6 +202,10 @@ typedef struct { pthread_t thread; volatile int served; // connections that got the whole metadata volatile int accepted; + // Answers the handshakes, then only ever sends a keep-alive a second: + // alive enough for every socket timeout, never any metadata. + bool trickle; + volatile bool quit; } mock_peer; static int read_all(int fd, void *buf, size_t n) { @@ -248,6 +253,12 @@ static void serve_one(mock_peer *mp, int fd) { MOCK_UT_ID, g_info_len); if (send_ext(fd, 0, ehs, (size_t)en, NULL, 0) != 0) return; + if (mp->trickle) { + static const uint8_t ka[4] = {0, 0, 0, 0}; + while (!mp->quit && write_all(fd, ka, 4) == 0) sleep(1); + return; + } + int npieces = (int)((g_info_len + 16383) / 16384); int sent = 0; while (sent < npieces) { @@ -305,6 +316,7 @@ static void peer_start(mock_peer *mp) { } static void peer_stop(mock_peer *mp) { + mp->quit = true; shutdown(mp->listen_fd, SHUT_RDWR); close(mp->listen_fd); pthread_join(mp->thread, NULL); @@ -611,7 +623,58 @@ static void test_metadata_cache(void) { printf(" PASSED\n"); } +// Sets *flag after `ms` (a thread of its own): Back pressed mid-fetch. +typedef struct { volatile bool *flag; int ms; } delayed_set; +static void *delayed_set_main(void *arg) { + delayed_set *d = arg; + usleep((useconds_t)d->ms * 1000); + *d->flag = true; + return NULL; +} + +static void test_trickling_peer_cancel(void) { + printf("[8/8] Magnet: a peer trickling keep-alives does not outlive a cancel...\n"); + trackers_reset(); + meta_cache_clear(g_hash_hex); + g_dht_n = 0; + + mock_peer mp; + peer_start(&mp); + mp.trickle = true; + mock_tracker *tr = tracker_add("udp://mock.test:1/announce"); + tr->peers[0] = loopback(mp.port); + tr->n = 1; + char magnet[256]; + magnet_for(magnet, sizeof(magnet), "udp%3a%2f%2fmock.test%3a1%2fannounce"); + + volatile bool cancel = false; + delayed_set ds = { &cancel, 1500 }; + pthread_t th; + pthread_create(&th, NULL, delayed_set_main, &ds); + + torrent_meta t; + peer_addr out[16]; + int out_n = 0; + char err[256] = {0}; + double t0 = now_s(); + int rc = torrent_load_magnet_peers_cancel(&t, magnet, out, 16, &out_n, &cancel, + err, sizeof(err)); + double dt = now_s() - t0; + pthread_join(th, NULL); + printf(" rc=%d in %.0f ms err='%s' accepted=%d\n", rc, dt * 1000, err, + mp.accepted); + CHECK(rc != 0); + CHECK(mp.accepted >= 1); // the worker did get stuck on it + // The keep-alives arrive every second, so the socket's 5 s timeout never + // fires: before the stop flag reached the reads, this waited for the + // peer to hang up -- never, here. + CHECK(dt < 4.0); + peer_stop(&mp); + printf(" PASSED\n"); +} + int main(void) { + signal(SIGPIPE, SIG_IGN); // the trickling peer writes to closed sockets printf("=========================================\n"); printf(" NX TORRENT PLAYER: OFFLINE ENGINE TESTS \n"); printf("=========================================\n\n"); @@ -627,6 +690,7 @@ int main(void) { test_dht_after_stale_peers(); test_trackerless_no_peers(); test_metadata_cache(); + test_trickling_peer_cancel(); meta_cache_clear(g_hash_hex); rmdir(TEST_META_DIR); diff --git a/pctest/test_tmdb.cpp b/pctest/test_tmdb.cpp index 9d0db3b..8b4425b 100644 --- a/pctest/test_tmdb.cpp +++ b/pctest/test_tmdb.cpp @@ -5,6 +5,7 @@ // own, people in a multi search, a series' specials season. #include +#include #include #include #include @@ -107,6 +108,20 @@ static void test_json_helpers() CHECK(json::str(t2, "a") == "x{\"y\":[1]}"); CHECK(json::str(t2, "c").empty()); CHECK(json::str(t2, "e") == "f"); + + // escape(): control characters too, and str() reads it all back. + std::string raw = std::string("tab\there \"q\" back\\slash\x01 nl\n"); + std::string esc = json::escape(raw); + CHECK(esc.find('\t') == std::string::npos && esc.find('\x01') == std::string::npos); + CHECK(json::str("{\"k\":\"" + esc + "\"}", "k") == raw.substr(0, raw.find('\x01')) + + "\x01 nl\n"); + // A lone surrogate half and \u0000 become U+FFFD, not broken UTF-8 / a NUL. + CHECK(json::str(R"J({"k":"a\ud800b"})J", "k") == "a\xEF\xBF\xBD" "b"); + CHECK(json::str(R"J({"k":"a\u0000b"})J", "k") == "a\xEF\xBF\xBD" "b"); + CHECK(json::str(R"J({"k":"😀"})J", "k") == "\xF0\x9F\x98\x80"); + // A number too long for 64 bits saturates instead of overflowing. + CHECK(json::integer(R"J({"n":123456789012345678901234567890})J", "n", 0) == LLONG_MAX); + CHECK(json::integer(R"J({"n":-42})J", "n", 0) == -42); std::printf(" PASSED\n"); }