diff --git a/Engine/Vfs/Test/https_download_timeout_tests.cpp b/Engine/Vfs/Test/https_download_timeout_tests.cpp index 81acd50..ac4963c 100644 --- a/Engine/Vfs/Test/https_download_timeout_tests.cpp +++ b/Engine/Vfs/Test/https_download_timeout_tests.cpp @@ -253,7 +253,7 @@ int main(int argc, char** argv) { fake_http::state.stop_at = stage; fake_http::state.timeout = timeout; result = get(); - check(!result.ok && result.error == (timeout ? ERROR_TIMEOUT : ERROR_CANCELLED), + check(!result.ok && result.error == static_cast(timeout ? ERROR_TIMEOUT : ERROR_CANCELLED), "pending operation or final completion reports the authoritative stop reason"); check(removed(), "stopped GET removes only its created output, including partial bytes"); check(closed(), "stopped request completes handle-closing lifetime handshake"); diff --git a/Engine/Vfs/https_download.cpp b/Engine/Vfs/https_download.cpp index 6ccb6b2..1c2f4f8 100644 --- a/Engine/Vfs/https_download.cpp +++ b/Engine/Vfs/https_download.cpp @@ -197,7 +197,7 @@ std::optional get_text(std::wstring_view url, std::uint64_t max_byt const auto path = std::filesystem::path(length ? std::wstring(folder, length) : L".") / (L"reskate-get-" + std::to_wstring(GetCurrentProcessId()) + L"-" + std::to_wstring(++serial) + L".tmp"); std::error_code error; -std::filesystem::remove(path, error); // discard a stale file left by an earlier process + std::filesystem::remove(path, error); // discard a stale file left by an earlier process const auto download = get(url, path, max_bytes, timeout_seconds, agent, watch); if (result) *result = download; std::optional text; diff --git a/Extension/Assets/Test/map_download_timeout_tests.cpp b/Extension/Assets/Test/map_download_timeout_tests.cpp index e20fbdf..fd8572f 100644 --- a/Extension/Assets/Test/map_download_timeout_tests.cpp +++ b/Extension/Assets/Test/map_download_timeout_tests.cpp @@ -90,10 +90,10 @@ Download get(std::wstring_view, const std::filesystem::path& destination, std::uint64_t max_bytes, std::uint32_t timeout_seconds, const wchar_t*, Watch* watch) { using namespace fixture; require(max_bytes == (8ull << 30), "size restriction retained"); - require(timeout_seconds == policy::transfer_us / policy::second_us, "HTTP timeout uses transfer budget"); + require(timeout_seconds == 4 * 60 * 60, "HTTP keeps its own four hour limit, not the session's"); archive = destination; const auto request = ++downloads; - http_budgets.push_back(policy::remaining(watch->deadline_us.load(), fixture::clock.load())); + http_budgets.push_back(watch->deadline_us.load()); std::ofstream(destination, std::ios::binary) << "partial archive"; watch->received.store(10); watch->total.store(100); @@ -303,12 +303,9 @@ void tests() { offer(); download::answer(true); wait([] { return downloading.load(); }, "download did not start for duplicate confirmation"); - const auto first_transfer_deadline = download::transfer_until.load(); clock.fetch_add(policy::second_us); download::answer(true); - require(download::transfer_until.load() == first_transfer_deadline && downloads.load() == 1 && - download::workers.load() == 1, - "only winning confirmation claims a worker and publishes the transfer deadline"); + require(downloads.load() == 1 && download::workers.load() == 1, "only winning confirmation claims a worker"); release_download.store(true); gate.notify_all(); start_apply(); @@ -345,15 +342,32 @@ void tests() { require(downloads.load() == 2, "timeout allows same package retry"); joined(); + // The session stops waiting (its deadline passes and it ends itself) while the map downloads: + // the download is not thrown away, the map installs and is applied, and the player is told to + // join again (the session would not take them back past its deadline). reset(); block_download = true; offer(); download::answer(true); wait([] { return downloading.load(); }, "download did not start"); clock.store(download::deadline.load()); + session.active = false; + fetching.store(false); + active_deadline.store(0); download::tick(); - idle("active session deadline"); - require(observed_cancel.load() && installs.load() == 0, "session expiry cancels active HTTP"); + require(download::view().stage == Stage::downloading && !download::watch.cancel.load() && noticed("still downloading"), + "session expiry leaves an active download running and says so"); + release_download.store(true); + gate.notify_all(); + start_apply(); + require(!observed_cancel.load() && installs.load() == 1, "download that outlived its session installs"); + merging.store(false); + download::tick(); + require(download::view().stage == Stage::joining, "detached download is applied"); + clock.fetch_add(2500000); + download::tick(); + require(download::view().stage == Stage::idle && rejoins.load() == 0 && stops.load() == 0 && noticed("Map installed"), + "detached download ends installed, without rejoining an expired session"); reset(); block_install = true; @@ -362,27 +376,28 @@ void tests() { wait([] { return installing.load(); }, "install did not start"); clock.store(download::deadline.load()); download::tick(); - idle("install timeout"); - require(installed_cancel.load() && applies.load() == 0 && noticed("could not be installed"), "installer observes cancellation"); - - reset(); - block_install = true; - offer(); - download::answer(true); - wait([] { return installing.load(); }, "install did not start for progress timeout"); - clock.store(download::deadline.load()); drain(); - idle("installer progress deadline"); - require(installed_cancel.load() && applies.load() == 0, - "installer progress callback enforces session deadline without client tick"); + require(!installed_cancel.load() && installs.load() == 1, "session expiry does not cancel an install under way"); + wait([] { return download::view().stage == Stage::applying; }, "expired install did not go on to applying"); + download::tick(); + require(applies.load() == 1, "expired install is still applied"); + merging.store(false); + download::tick(); + fetching.store(false); + session.active = false; + clock.fetch_add(2500000); + download::tick(); + require(download::view().stage == Stage::idle && rejoins.load() == 0 && noticed("Map installed"), + "map installed after the session's deadline is not rejoined"); + // A live merge cannot be stopped: a fresh need waits for one left behind by a cancelled attempt. reset(); offer(); applying(); - clock.store(download::deadline.load()); + active_deadline.store(download::deadline.load() + 1); // another session's own attempt download::tick(); - idle("apply timeout"); - require(merging.load() && rejoins.load() == 0, "late atomic merge must not join"); + idle("apply cancelled by another session", 0); + require(merging.load() && rejoins.load() == 0, "cancelled atomic merge must not join"); download::offer({"Team-Map-1.0.0", "Test map", true}); require(download::view().stage == Stage::idle && noticed("still finishing"), "busy live merge blocks retry"); merging.store(false); @@ -422,21 +437,19 @@ void tests() { require(downloads.load() == 0 && noticed("timed out"), "confirmation never extends existing session budget"); reset(); - offer(clock.load() + policy::completion_reserve_us); + offer(clock.load() + 1); download::answer(true); - idle("completion reserve boundary"); - require(downloads.load() == 0, "no transfer starts when only completion reserve remains"); - - reset(); - block_download = true; - offer(clock.load() + policy::completion_reserve_us + 1); - download::answer(true); - wait([] { return downloading.load(); }, "last microsecond transfer did not start"); - require(download::transfer_until.load() == clock.load() + 1, "near-boundary transfer keeps reserve"); - clock.fetch_add(1); + wait([] { return download::view().stage == Stage::applying; }, "download confirmed in the session's last microsecond did not start"); + require(downloads.load() == 1 && http_budgets[0] == 0, "a download confirmed in time starts, with no session deadline on it"); + drain(); download::tick(); - idle("last transfer microsecond"); - require(observed_cancel.load() && installs.load() == 0, "transfer exact boundary cancels without install"); + merging.store(false); + download::tick(); + fetching.store(false); + session.active = false; + clock.fetch_add(2500000); + download::tick(); + require(download::view().stage == Stage::idle && noticed("Map installed"), "late download still ends installed"); reset(); block_download = true; @@ -460,26 +473,12 @@ void tests() { stops.store(0); joined(); - reset(); - block_download = true; - offer(); - download::answer(true); - wait([] { return downloading.load(); }, "HTTP did not start for completion race"); - clock.store(download::transfer_until.load()); - // Return a successful HTTP response before tick notices its expired deadline. - drain(); - download::tick(); - require(download::view().stage == Stage::idle && installs.load() == 0, - "success racing the transfer deadline cannot reach installation"); - idle("transfer completion race"); - reset(); fallback = true; offer(); applying(); require(downloads.load() == 2 && http_budgets.size() == 2, "404 selects newest fallback"); - require(http_budgets[0] == policy::transfer_us, "initial request gets at most 27 minutes"); - require(http_budgets[1] == policy::transfer_us - 60 * policy::second_us, "fallback loses first request's minute"); + require(http_budgets[0] == 0 && http_budgets[1] == 0, "neither request is held to the session's deadline"); joined(); reset(); @@ -532,8 +531,7 @@ void tests() { download::offer({"Team-Map-1.0.0", "Test map", true, false, "", fresh_deadline}); wait([] { return download::asking(); }, "fresh same-package need was not retaken"); drain(); - require(download::deadline.load() == fresh_deadline && fresh_deadline != abandoned_deadline && - download::transfer_until.load() == 0 && stops.load() == 0, + require(download::deadline.load() == fresh_deadline && fresh_deadline != abandoned_deadline && stops.load() == 0, "fresh same-package attempt replaces stale confirmation without stopping its new session"); applying(); joined(); @@ -559,9 +557,8 @@ void tests() { download::tick(); wait([] { return download::asking(); }, "queued same-package need was not offered after drain"); drain(); - require(download::deadline.load() == queued_deadline && download::transfer_until.load() == 0 && - !download::pending && stops.load() == 0, - "queued attempt keeps fresh deadline and resets previous transfer budget"); + require(download::deadline.load() == queued_deadline && !download::pending && stops.load() == 0, + "queued attempt keeps its fresh deadline"); applying(); require(downloads.load() == 2, "queued fresh attempt starts exactly one new transfer"); joined(); diff --git a/Extension/Assets/map_download.cpp b/Extension/Assets/map_download.cpp index 2d37f8b..96e3dc8 100644 --- a/Extension/Assets/map_download.cpp +++ b/Extension/Assets/map_download.cpp @@ -26,14 +26,20 @@ namespace package = thunderstore_package; constexpr wchar_t agent[] = L"ReSkate"; constexpr char community[] = "reskate"; -// Keep the existing package size restriction; time is bounded by the session policy. +// A map is at most a few gigabytes (an archive of one holds four); the download may take as +// long as a slow line needs for that, whether or not the session is still waiting on it. constexpr std::uint64_t largest_download = 8ull << 30; +constexpr std::uint32_t download_seconds = 4 * 60 * 60; namespace policy = multiplayer::map_fetch_policy; std::mutex mutex; std::atomic stage{Stage::idle}; std::atomic workers{}; -std::atomic deadline{}, ask_until{}, transfer_until{}; +std::atomic deadline{}, ask_until{}; +// The session stopped waiting while the map was coming (its deadline passed, the server gave up +// on the player, the connection went): the map is fetched and installed all the same, and the +// player is told to join again. +std::atomic detached{}; multiplayer::MapNeed need; // under mutex, while not idle std::optional pending; // a fresh attempt waiting for cancelled work to drain package::Name wanted; // under mutex @@ -99,13 +105,18 @@ void fail(std::string reason, bool retry = false) { overlay::notify(overlay::NoticeLevel::warning, update ? "Map version differs" : "Map not installed", std::move(text)); } +// Whether the download has begun (or is done): from then on it goes to the end on its own time. +bool under_way(Stage now) { + return now == Stage::downloading || now == Stage::installing || now == Stage::applying || now == Stage::joining; +} + bool continuing() { - if (stage.load() == Stage::idle) return false; - if (policy::expired(deadline.load(), multiplayer::now_us())) { - const auto now = stage.load(); - fail(now == Stage::installing || now == Stage::applying - ? "The map could not be installed before the session timeout." - : "Map download timed out. Please try joining the server again.", true); + const auto now = stage.load(); + if (now == Stage::idle) return false; + // The session's deadline ends a check or a question it outlived; a download under way is + // not thrown away with it (a large map on a slow line would never come at all). + if (!under_way(now) && policy::expired(deadline.load(), multiplayer::now_us())) { + fail("Map download timed out. Please try joining the server again.", true); return false; } return !watch.cancel.load(); @@ -220,12 +231,10 @@ void fetch() { fs::remove(archive, error); watch.received.store(0); watch.total.store(0); - // One transfer budget across fallback requests; leave time for installation and joining. - const auto transfer = transfer_until.load(); + // (The session's deadline is not the download's: only its own time limit stops it.) const auto download = [&](const std::string& selected) { - watch.deadline_us.store(transfer); - return https::get(package::download_url(name, selected), archive, largest_download, - static_cast(policy::transfer_us / policy::second_us), agent, &watch); + watch.deadline_us.store(0); + return https::get(package::download_url(name, selected), archive, largest_download, download_seconds, agent, &watch); }; auto result = download(get); // The host's version can be gone from Thunderstore: the newest is the same map. @@ -238,17 +247,12 @@ void fetch() { if (!result.ok) { fs::remove(archive, error); if (!continuing()) return; - if (result.error == ERROR_TIMEOUT || result.error == ERROR_WINHTTP_TIMEOUT || - policy::expired(transfer, multiplayer::now_us())) - return fail("Map download timed out. Please try joining the server again.", true); + if (result.error == ERROR_TIMEOUT || result.error == ERROR_WINHTTP_TIMEOUT) + return fail("The download timed out. Please try joining the server again.", true); return fail(result.http_status && result.http_status != 200 ? "The download failed (Thunderstore answered " + std::to_string(result.http_status) + ")." : "The download failed (Windows error " + std::to_string(result.error) + ")."); } - if (policy::expired(transfer, multiplayer::now_us())) { - fs::remove(archive, error); - return fail("Map download timed out. Please try joining the server again.", true); - } if (!advance(Stage::downloading, Stage::installing)) { fs::remove(archive, error); return; @@ -319,7 +323,9 @@ void start(void (*work)()) { void offer(const multiplayer::MapNeed& asked) { std::unique_lock lock(mutex); - if (stage.load() != Stage::idle && asked.deadline_us && asked.deadline_us != deadline.load()) { + // (Another session's need cancels this attempt, unless its own session has stopped waiting + // on it already: then it finishes, and the new need is turned away below.) + if (stage.load() != Stage::idle && !detached.load() && asked.deadline_us && asked.deadline_us != deadline.load()) { lock.unlock(); fail("Map download cancelled.", true); lock.lock(); @@ -369,7 +375,7 @@ void offer(const multiplayer::MapNeed& asked) { const auto now = multiplayer::now_us(); deadline.store(asked.deadline_us ? asked.deadline_us : now + policy::session_us); ask_until.store(0); - transfer_until.store(0); + detached.store(false); watch.deadline_us.store(deadline.load()); stop_install.store(false); apply_wanted.store(false); @@ -396,19 +402,11 @@ void answer(bool download) noexcept { if (stage.load() == Stage::asking || stage.load() == Stage::downloading) fail("Map download cancelled.", true); } else { - bool no_time{}; - { - // Publish the stage and claim its worker together. Cancellation cannot allow - // a fresh offer to reuse the globals before this thread starts its worker. - std::lock_guard lock(mutex); - auto asked = Stage::asking; - if (!watch.cancel.load() && stage.compare_exchange_strong(asked, Stage::downloading)) { - transfer_until.store(policy::transfer_deadline(deadline.load(), now)); - no_time = policy::expired(transfer_until.load(), now) && !installed_off; - if (!no_time) start(fetch); - } - } - if (no_time) fail("Map download timed out. Please try joining the server again.", true); + // Publish the stage and claim its worker together. Cancellation cannot allow + // a fresh offer to reuse the globals before this thread starts its worker. + std::lock_guard lock(mutex); + auto asked = Stage::asking; + if (!watch.cancel.load() && stage.compare_exchange_strong(asked, Stage::downloading)) start(fetch); } } catch (...) { fail("The map download could not be started. Please try joining again.", true); @@ -473,16 +471,20 @@ void tick() { if (stage.compare_exchange_strong(now, Stage::idle)) last_failed.clear(); now = stage.load(); } - if (session.active && now != Stage::joining && multiplayer::map_fetch_deadline() != deadline.load()) + const bool gave_up = policy::expired(deadline.load(), multiplayer::now_us()) || + session.status.find("stopped waiting for the map download") != std::string::npos; + // The session stopped waiting while the download was under way: it finishes all the same. + if (!session.active && under_way(now) && !detached.exchange(true) && gave_up) + overlay::notify(overlay::NoticeLevel::warning, "Map still downloading", + "The " + [] { std::lock_guard lock(mutex); return host_word(need.server); }() + + " stopped waiting for the map. It goes on downloading and installs when it is done; join again then."); + if (session.active && !detached.load() && now != Stage::joining && multiplayer::map_fetch_deadline() != deadline.load()) fail("Map download cancelled.", true); - if (!session.active && session.status.find("stopped waiting for the map download") != std::string::npos) + if (!session.active && !under_way(now) && gave_up) fail("The server stopped waiting for the map download.", true); if (continuing() && now == Stage::asking && policy::expired(ask_until.load(), multiplayer::now_us())) fail("The map download was not confirmed within two minutes. Please try joining again.", true); - if (continuing() && now == Stage::downloading && - policy::expired(transfer_until.load(), multiplayer::now_us())) - fail("Map download timed out. Please try joining the server again.", true); now = stage.load(); } // The session leaves the map alone until it has been applied (an update's level is one the @@ -530,9 +532,11 @@ void tick() { last_failed.clear(); } if (!continuing() || !stage.compare_exchange_strong(now, Stage::idle)) return; - // The session ended while the map came (the host left, the connection dropped): back to it. - if (!multiplayer::model().active && !multiplayer::queue_command("rejoin", "", "")) - overlay::notify(overlay::NoticeLevel::warning, "Map installed", "Join the session again from the server browser."); + // The session ended while the map came (the host left, the connection dropped): back to it, + // while it would still have the player. Past its deadline it would not. + if (!multiplayer::model().active && + (policy::expired(deadline.load(), multiplayer::now_us()) || !multiplayer::queue_command("rejoin", "", ""))) + overlay::notify(overlay::NoticeLevel::info, "Map installed", "Join the session again from the server browser."); } } diff --git a/Extension/Assets/map_download.h b/Extension/Assets/map_download.h index 4cf7525..4c0ae27 100644 --- a/Extension/Assets/map_download.h +++ b/Extension/Assets/map_download.h @@ -9,9 +9,11 @@ // player is asked; on yes the package is downloaded, installed into Mods and applied as a live // mod (live_mods.h). The session stays connected meanwhile and loads the map once it is there; // on no, or when the map cannot be had, the session is left. -// Checking, confirmation, transfer, installation, applying and joining share the session's -// local monotonic 30-minute deadline. Confirmation gets two minutes; transfers at most 27, -// leaving three for completion. A cancelled worker drains before another attempt can begin. +// The session waits 30 minutes on its local monotonic clock for the whole of it (checking, +// confirmation, download, installation, applying and joining); confirmation gets two of them. +// A download that has begun is not cut short when the session stops waiting: it finishes, +// installs and is applied, and the player is told to join again. A cancelled worker drains +// before another attempt can begin. // // Only maps come in this way. The package has to be listed as a map in ReSkate's Thunderstore // community, not deprecated and not rejected by its moderators; what is installed has to be a diff --git a/Extension/Multiplayer/Net/map_fetch_policy.h b/Extension/Multiplayer/Net/map_fetch_policy.h index 5139b1e..dcd497e 100644 --- a/Extension/Multiplayer/Net/map_fetch_policy.h +++ b/Extension/Multiplayer/Net/map_fetch_policy.h @@ -4,9 +4,10 @@ namespace dingosdk::multiplayer::map_fetch_policy { inline constexpr std::uint64_t second_us = 1000000; +// How long a session waits on a map being fetched: checking, confirmation, download, install, +// apply and join. The download itself is not cut short by it: one the session gave up on goes +// on to the end and installs, and the player joins again (map_download.cpp). inline constexpr std::uint64_t session_us = 30ull * 60 * second_us; -inline constexpr std::uint64_t transfer_us = 27ull * 60 * second_us; -inline constexpr std::uint64_t completion_reserve_us = 3ull * 60 * second_us; inline constexpr std::uint64_t confirmation_us = 2ull * 60 * second_us; inline constexpr std::uint64_t installed_load_us = 30ull * second_us; @@ -17,10 +18,6 @@ constexpr bool expired(std::uint64_t deadline, std::uint64_t now) noexcept { constexpr std::uint64_t remaining(std::uint64_t deadline, std::uint64_t now) noexcept { return now < deadline ? deadline - now : 0; } -constexpr std::uint64_t transfer_deadline(std::uint64_t overall, std::uint64_t now) noexcept { - const auto available = remaining(overall, now); - return now + std::min(transfer_us, available > completion_reserve_us ? available - completion_reserve_us : 0); -} constexpr bool host_expired(std::uint64_t started, std::uint64_t now) noexcept { return started && now >= started && now - started >= session_us; } diff --git a/Extension/Multiplayer/Test/map_fetch_policy_tests.cpp b/Extension/Multiplayer/Test/map_fetch_policy_tests.cpp index a263f1d..3980d95 100644 --- a/Extension/Multiplayer/Test/map_fetch_policy_tests.cpp +++ b/Extension/Multiplayer/Test/map_fetch_policy_tests.cpp @@ -18,11 +18,8 @@ int main() { require(policy::session_us == 1800 * policy::second_us, "30 minute session"); require(dingosdk::multiplayer::map_fetch_limit_us == policy::session_us, "host protocol and local downloader share the session budget"); - require(policy::transfer_us == 1620 * policy::second_us, "27 minute transfer"); - require(policy::completion_reserve_us == 180 * policy::second_us, "3 minute completion reserve"); require(policy::confirmation_us == 120 * policy::second_us, "2 minute confirmation"); require(policy::installed_load_us == 30 * policy::second_us, "30 second installed-map load"); - require(policy::transfer_us + policy::completion_reserve_us == policy::session_us, "budgets partition session"); require(!policy::expired(0, 0) && !policy::expired(0, std::numeric_limits::max()), "zero means no deadline"); require(!policy::host_expired(0, policy::session_us), "host zero means no fetch"); @@ -31,8 +28,7 @@ int main() { // Arbitrary process clock epochs must not affect durations or host/client agreement. for (const auto origin : std::array{1, 500000000000ull, 800000000000000ull}) { for (const auto duration : std::array{ - policy::session_us, policy::transfer_us, policy::completion_reserve_us, - policy::confirmation_us, policy::installed_load_us}) { + policy::session_us, policy::confirmation_us, policy::installed_load_us}) { const auto deadline = origin + duration; require(!policy::expired(deadline, deadline - 1), "before boundary"); require(policy::expired(deadline, deadline), "exact boundary"); @@ -48,29 +44,6 @@ int main() { require(policy::host_expired(origin, overall + 1), "host after boundary"); require(policy::host_expired(origin, overall) == policy::expired(overall, overall), "host and downloader share duration"); - const auto transfer = policy::transfer_deadline(overall, origin); - require(transfer == origin + policy::transfer_us, "full transfer budget"); - require(overall - transfer == policy::completion_reserve_us, "completion remains reserved"); - const auto confirmed = origin + policy::confirmation_us; - require(policy::transfer_deadline(overall, confirmed) == transfer, - "confirmation consumes session, not extra time"); - const auto fallback = confirmed + 600 * policy::second_us; - require(policy::remaining(transfer, fallback) == transfer - fallback, - "fallback consumes original transfer budget"); - require(policy::transfer_deadline(overall, fallback) == transfer, - "fallback cannot restart the budget"); - const auto reserve_start = overall - policy::completion_reserve_us; - require(policy::transfer_deadline(overall, reserve_start - 1) == reserve_start, - "one microsecond transfer remains"); - require(policy::transfer_deadline(overall, reserve_start) == reserve_start, - "exact reserve refuses transfer"); - require(policy::transfer_deadline(overall, reserve_start + 1) == reserve_start + 1, - "less than reserve refuses transfer"); - require(policy::transfer_deadline(overall, overall) == overall, "expired session refuses transfer"); - require(policy::transfer_deadline(overall, overall + 1) == overall + 1, "past session refuses transfer"); - require(policy::transfer_deadline(overall + policy::session_us, origin) == transfer, - "long external deadline still caps transfer"); - require(policy::transfer_deadline(0, origin) == origin, "missing session budget refuses transfer"); } constexpr auto host_clock = 90000000000000ull; constexpr auto client_clock = 700000000000ull; diff --git a/README.md b/README.md index 70f1b48..73e78d4 100644 --- a/README.md +++ b/README.md @@ -84,17 +84,17 @@ custom map and then join a server on another map, the screen can stay black ### Multiplayer map downloads Missing multiplayer custom maps can be downloaded from Thunderstore after confirmation. -The confirmation expires after two minutes. Checking, downloading, installing, applying and -joining share one 30-minute budget; HTTP transfers get at most 27 minutes and leave three minutes -for completion (confirmation and metadata checks reduce the available transfer time). -Dedicated servers and player-hosted lobbies enforce the same overall limit on their own -monotonic clocks. Ordinary map loading still gets 180 seconds; loading after a live installation -still gets 30 seconds, capped by the remaining budget. +The confirmation expires after two minutes. A session waits 30 minutes in all for the map to be +checked, downloaded, installed, applied and joined; dedicated servers and player-hosted lobbies +enforce the same limit on their own monotonic clocks. Ordinary map loading still gets 180 +seconds; loading after a live installation still gets 30 seconds. -Timeouts cancel HTTP transfers and remove incomplete temporary downloads. Installation stops -before replacing an existing mod when cancellation is observed. A filesystem commit or live -merge already in progress finishes safely rather than being forcibly interrupted; it cannot -restart a timed-out join. Retry after that work has finished, without restarting ReSkate. +A download that has begun is not cut short when the session stops waiting: a large map on a slow +connection goes on downloading (up to four hours), installs and is applied, and you are told to +join the server again. Declining, or cancelling the download, stops it and removes the +incomplete file. Installation stops before replacing an existing mod when cancellation is +observed; a filesystem commit or live merge already in progress finishes safely rather than +being forcibly interrupted. Retry after that work has finished, without restarting ReSkate. ### Controls