From 8eddcab142aa66b6b98f2b62ed92f6e710383c55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:11:27 +0000 Subject: [PATCH] Coordinate custom-map downloads with the multiplayer deadline Co-authored-by: Jean-LucTch <88255067+Jean-LucTch@users.noreply.github.com> --- CMakeLists.txt | 9 + Engine/Vfs/https_download.cpp | 143 +++++- Engine/Vfs/https_download.h | 4 +- .../Test/map_download_timeout_tests.cpp | 455 ++++++++++++++++++ Extension/Assets/map_download.cpp | 181 +++++-- Extension/Assets/map_download.h | 3 + Extension/Multiplayer/Net/map_fetch_policy.h | 27 ++ Extension/Multiplayer/Net/protocol.h | 7 +- Extension/Multiplayer/Session/session.cpp | 16 +- Extension/Multiplayer/Session/session.h | 2 + .../Multiplayer/Session/session_commands.cpp | 3 + .../Multiplayer/Session/session_internal.h | 4 +- .../Multiplayer/Session/session_receive.cpp | 17 + .../Test/map_fetch_policy_tests.cpp | 86 ++++ Launcher/mod_manager.cpp | 7 + README.md | 15 + Server/server_host.cpp | 10 +- 17 files changed, 919 insertions(+), 70 deletions(-) create mode 100644 Extension/Assets/Test/map_download_timeout_tests.cpp create mode 100644 Extension/Multiplayer/Net/map_fetch_policy.h create mode 100644 Extension/Multiplayer/Test/map_fetch_policy_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 007c40d..9dc13fe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -62,7 +62,16 @@ endif() option(DINGOSDK_BUILD_MULTIPLAYER_TESTS "Build multiplayer protocol regression tests" OFF) if(DINGOSDK_BUILD_MULTIPLAYER_TESTS) enable_testing() + add_executable(dingosdk_map_fetch_policy_tests Extension/Multiplayer/Test/map_fetch_policy_tests.cpp) + target_include_directories(dingosdk_map_fetch_policy_tests PRIVATE "${PROJECT_SOURCE_DIR}") + add_test(NAME map_fetch_policy COMMAND dingosdk_map_fetch_policy_tests) if(WIN32) + add_executable(dingosdk_map_download_timeout_tests Extension/Assets/Test/map_download_timeout_tests.cpp + Engine/Vfs/thunderstore_package.cpp) + target_include_directories(dingosdk_map_download_timeout_tests PRIVATE "${PROJECT_SOURCE_DIR}") + target_link_libraries(dingosdk_map_download_timeout_tests PRIVATE dingosdk_json) + add_test(NAME map_download_timeout COMMAND dingosdk_map_download_timeout_tests + "${CMAKE_CURRENT_BINARY_DIR}/map-download-fixtures") add_executable(dingosdk_voice_tests Extension/Multiplayer/Test/voice_chat_tests.cpp Extension/Multiplayer/Net/protocol.cpp Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp) target_link_libraries(dingosdk_voice_tests PRIVATE dingosdk_lz4 dingosdk_zstd) diff --git a/Engine/Vfs/https_download.cpp b/Engine/Vfs/https_download.cpp index 4ea1975..d5f79a3 100644 --- a/Engine/Vfs/https_download.cpp +++ b/Engine/Vfs/https_download.cpp @@ -3,26 +3,107 @@ #include #include #include +#include +#include #include #include +#include #include +#include namespace dingosdk::https { namespace { +std::uint64_t now_us() noexcept { + return static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); +} + struct Handle { HANDLE value{INVALID_HANDLE_VALUE}; - ~Handle() { if (value != INVALID_HANDLE_VALUE) CloseHandle(value); } + const std::filesystem::path& path; + bool complete{}; + ~Handle() { + if (value != INVALID_HANDLE_VALUE) { + CloseHandle(value); + if (!complete) DeleteFileW(path.c_str()); + } + } }; struct Internet { HINTERNET value{}; ~Internet() { if (value) WinHttpCloseHandle(value); } }; +struct AsyncRequest { + HINTERNET value{}; + std::mutex mutex; + std::condition_variable changed; + DWORD expected{}, error{}, read{}; + bool completed{}, closed{}; + + ~AsyncRequest() { close(); } + + void close() { + if (!value) return; + // Async closure interrupts pending I/O. Keep the context and read buffer + // alive until WinHTTP's final callback, including on cancellation. + WinHttpCloseHandle(value); + std::unique_lock lock(mutex); + changed.wait(lock, [&] { return closed; }); + value = nullptr; + } + + static void CALLBACK callback(HINTERNET, DWORD_PTR context, DWORD status, void* info, DWORD size) { + if (!context) return; + auto& request = *reinterpret_cast(context); + std::lock_guard lock(request.mutex); + if (status == WINHTTP_CALLBACK_STATUS_HANDLE_CLOSING) { + request.closed = true; + } else if (status == WINHTTP_CALLBACK_STATUS_REQUEST_ERROR) { + request.error = static_cast(info)->dwError; + request.completed = true; + } else if (status == request.expected) { + if (status == WINHTTP_CALLBACK_STATUS_READ_COMPLETE) request.read = size; + request.completed = true; + } + request.changed.notify_all(); + } + + void prepare(DWORD status) { + std::lock_guard lock(mutex); + expected = status; + completed = false; + error = read = 0; + } + + template + DWORD wait(const Stop& stop) { + std::unique_lock lock(mutex); + for (;;) { + if (const auto stopped = stop()) return stopped; + if (completed) return error; + changed.wait_for(lock, std::chrono::milliseconds(20)); + } + } +}; } Download get(std::wstring_view url, const std::filesystem::path& destination, std::uint64_t max_bytes, std::uint32_t timeout_seconds, const wchar_t* agent, Watch* watch) { Download result; - const auto fail = [&](DWORD error) { result.error = error; return result; }; + const auto started = now_us(); + const auto timeout_deadline = started + std::uint64_t{timeout_seconds} * 1000000; + const auto stop = [&]() -> DWORD { + if (watch && watch->cancel.load()) return ERROR_CANCELLED; + const auto external = watch ? watch->deadline_us.load() : 0; + const auto deadline = external && external < timeout_deadline ? external : timeout_deadline; + return now_us() >= deadline ? ERROR_TIMEOUT : ERROR_SUCCESS; + }; + const auto fail = [&](DWORD error) { + const auto stopped = stop(); + result.error = stopped ? stopped : error; + return result; + }; + if (const auto error = stop()) return fail(error); if (url.empty() || url.size() >= 4096 || url.find_first_of(L"\r\n\t #") != std::wstring_view::npos) return fail(ERROR_INVALID_PARAMETER); const std::wstring address(url); @@ -35,7 +116,7 @@ Download get(std::wstring_view url, const std::filesystem::path& destination, const std::wstring host(parts.lpszHostName, parts.dwHostNameLength); std::wstring resource = parts.dwUrlPathLength ? std::wstring(parts.lpszUrlPath, parts.dwUrlPathLength) : L"/"; if (parts.dwExtraInfoLength) resource.append(parts.lpszExtraInfo, parts.dwExtraInfoLength); - Internet session{WinHttpOpen(agent, WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, nullptr, nullptr, 0)}; + Internet session{WinHttpOpen(agent, WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, nullptr, nullptr, WINHTTP_FLAG_ASYNC)}; if (!session.value) return fail(GetLastError()); if (!WinHttpSetTimeouts(session.value, 5000, 5000, 10000, 10000)) return fail(GetLastError()); Internet connection{WinHttpConnect(session.value, host.c_str(), parts.nPort, 0)}; @@ -43,30 +124,56 @@ Download get(std::wstring_view url, const std::filesystem::path& destination, Internet request{WinHttpOpenRequest(connection.value, L"GET", resource.c_str(), nullptr, nullptr, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE)}; if (!request.value) return fail(GetLastError()); + // Declared before AsyncRequest so pending reads cannot outlive their buffer. + std::array buffer{}; + AsyncRequest async; DWORD redirect = WINHTTP_OPTION_REDIRECT_POLICY_DISALLOW_HTTPS_TO_HTTP; DWORD logon = WINHTTP_AUTOLOGON_SECURITY_LEVEL_HIGH; if (!WinHttpSetOption(request.value, WINHTTP_OPTION_REDIRECT_POLICY, &redirect, sizeof(redirect)) || !WinHttpSetOption(request.value, WINHTTP_OPTION_AUTOLOGON_POLICY, &logon, sizeof(logon))) return fail(GetLastError()); - const auto started = GetTickCount64(); - if (!WinHttpSendRequest(request.value, nullptr, 0, nullptr, 0, 0, 0) || !WinHttpReceiveResponse(request.value, nullptr)) + DWORD_PTR context = reinterpret_cast(&async); + if (!WinHttpSetOption(request.value, WINHTTP_OPTION_CONTEXT_VALUE, &context, sizeof(context))) return fail(GetLastError()); + if (WinHttpSetStatusCallback(request.value, AsyncRequest::callback, + WINHTTP_CALLBACK_FLAG_SENDREQUEST_COMPLETE | WINHTTP_CALLBACK_FLAG_HEADERS_AVAILABLE | + WINHTTP_CALLBACK_FLAG_READ_COMPLETE | WINHTTP_CALLBACK_FLAG_REQUEST_ERROR | + WINHTTP_CALLBACK_FLAG_HANDLES, 0) == WINHTTP_INVALID_STATUS_CALLBACK) + return fail(GetLastError()); + async.value = std::exchange(request.value, nullptr); + const auto finish = [&](BOOL accepted) -> DWORD { + if (!accepted) { + const auto error = GetLastError(); + if (error != ERROR_IO_PENDING) return error; + } + return async.wait(stop); + }; + if (const auto error = stop()) return fail(error); + async.prepare(WINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE); + if (const auto error = finish(WinHttpSendRequest(async.value, nullptr, 0, nullptr, 0, 0, context))) + return fail(error); + async.prepare(WINHTTP_CALLBACK_STATUS_HEADERS_AVAILABLE); + if (const auto error = stop()) return fail(error); + if (const auto error = finish(WinHttpReceiveResponse(async.value, nullptr))) return fail(error); DWORD size = sizeof(result.http_status); - if (!WinHttpQueryHeaders(request.value, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + if (!WinHttpQueryHeaders(async.value, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, nullptr, &result.http_status, &size, nullptr)) return fail(GetLastError()); if (result.http_status != 200) return fail(ERROR_BAD_NET_RESP); DWORD length{}; size = sizeof(length); - if (WinHttpQueryHeaders(request.value, WINHTTP_QUERY_CONTENT_LENGTH | WINHTTP_QUERY_FLAG_NUMBER, + if (WinHttpQueryHeaders(async.value, WINHTTP_QUERY_CONTENT_LENGTH | WINHTTP_QUERY_FLAG_NUMBER, nullptr, &length, &size, nullptr) && length > max_bytes) return fail(ERROR_FILE_TOO_LARGE); if (watch) watch->total.store(length), watch->received.store(0); - Handle output{CreateFileW(destination.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr)}; + if (const auto error = stop()) return fail(error); + Handle output{CreateFileW(destination.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr), + destination}; if (output.value == INVALID_HANDLE_VALUE) return fail(GetLastError()); - std::array buffer{}; std::uint64_t total{}; for (;;) { - if (GetTickCount64() - started >= std::uint64_t{timeout_seconds} * 1000) return fail(ERROR_TIMEOUT); - if (watch && watch->cancel.load()) return fail(ERROR_CANCELLED); - DWORD read{}, written{}; - if (!WinHttpReadData(request.value, buffer.data(), static_cast(buffer.size()), &read)) return fail(GetLastError()); + if (const auto error = stop()) return fail(error); + async.prepare(WINHTTP_CALLBACK_STATUS_READ_COMPLETE); + if (const auto error = finish(WinHttpReadData(async.value, buffer.data(), static_cast(buffer.size()), nullptr))) + return fail(error); + const auto read = async.read; + DWORD written{}; if (!read) break; if (total + read > max_bytes) return fail(ERROR_FILE_TOO_LARGE); if (!WriteFile(output.value, buffer.data(), read, &written, nullptr) || written != read) return fail(ERROR_WRITE_FAULT); @@ -75,27 +182,29 @@ Download get(std::wstring_view url, const std::filesystem::path& destination, } if (!total) return fail(ERROR_BAD_LENGTH); if (!FlushFileBuffers(output.value)) return fail(GetLastError()); + async.close(); + if (const auto error = stop()) return fail(error); + output.complete = true; result.ok = true; return result; } std::optional get_text(std::wstring_view url, std::uint64_t max_bytes, - std::uint32_t timeout_seconds, const wchar_t* agent, Download* result) { + std::uint32_t timeout_seconds, const wchar_t* agent, Download* result, Watch* watch) { static std::atomic serial{}; wchar_t folder[MAX_PATH + 1]{}; const auto length = GetTempPathW(MAX_PATH, folder); 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); // get needs a fresh destination - const auto download = get(url, path, max_bytes, timeout_seconds, agent); + const auto download = get(url, path, max_bytes, timeout_seconds, agent, watch); if (result) *result = download; std::optional text; if (download.ok) { std::ifstream in(path, std::ios::binary); text.emplace(std::istreambuf_iterator(in), std::istreambuf_iterator()); } - std::filesystem::remove(path, error); + if (download.ok) std::filesystem::remove(path, error); return text; } } diff --git a/Engine/Vfs/https_download.h b/Engine/Vfs/https_download.h index 1b9073f..aa71675 100644 --- a/Engine/Vfs/https_download.h +++ b/Engine/Vfs/https_download.h @@ -16,6 +16,8 @@ struct Download { struct Watch { std::atomic received{}, total{}; std::atomic cancel{}; + // Absolute steady_clock epoch microseconds; zero leaves only the GET timeout. + std::atomic deadline_us{}; }; // Streams an HTTPS GET into `destination`, which must not exist yet. Follows // redirects that stay on HTTPS; refuses bodies over `max_bytes` or transfers @@ -25,5 +27,5 @@ Download get(std::wstring_view url, const std::filesystem::path& destination, // The body of the same GET, for an answer small enough to hold: nothing when it // fails, and `result`, when given, says how. std::optional get_text(std::wstring_view url, std::uint64_t max_bytes, - std::uint32_t timeout_seconds, const wchar_t* agent, Download* result = nullptr); + std::uint32_t timeout_seconds, const wchar_t* agent, Download* result = nullptr, Watch* watch = nullptr); } diff --git a/Extension/Assets/Test/map_download_timeout_tests.cpp b/Extension/Assets/Test/map_download_timeout_tests.cpp new file mode 100644 index 0000000..a1e2392 --- /dev/null +++ b/Extension/Assets/Test/map_download_timeout_tests.cpp @@ -0,0 +1,455 @@ +#include "Extension/Assets/map_download.h" +#include "Extension/Assets/live_mods.h" +#include "Extension/Multiplayer/Session/monotonic_clock.h" +#include "Extension/Multiplayer/Net/protocol.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Vfs/https_download.h" +#include "Engine/Vfs/mod_catalog.h" +#include "Launcher/mod_manager.h" +#include "Extension/UI/Overlay/overlay.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fixture { +namespace fs = std::filesystem; +namespace policy = dingosdk::multiplayer::map_fetch_policy; +fs::path root; +std::atomic clock{1000000}; +std::atomic downloading{}, installing{}, release_download{}, release_install{}, merging{}; +std::atomic fetching{true}, held{}, observed_cancel{}, installed_cancel{}; +std::atomic downloads{}, installs{}, applies{}, stops{}, rejoins{}; +bool block_download{}, block_install{}, slow{}, transfer_error{}, fallback{}; +dingosdk::MultiplayerModel session; +std::mutex messages_mutex, gate_mutex; +std::condition_variable gate; +std::vector notices; +std::vector http_budgets; +fs::path archive; + +void require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} +template +void wait(Predicate predicate, const char* message) { + const auto end = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!predicate()) { + if (std::chrono::steady_clock::now() >= end) throw std::runtime_error(message); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } +} +bool noticed(std::string_view text) { + std::lock_guard lock(messages_mutex); + for (const auto& notice : notices) + if (notice.find(text) != std::string::npos) return true; + return false; +} +} + +namespace dingosdk::multiplayer { +std::uint64_t fake_now_us() noexcept { return fixture::clock.load(); } +} + +// Compile the actual lifecycle, not a parallel model. Preincluded headers keep these +// substitutions local to this translation unit; archives also stay in the test fixture. +DWORD test_get_temp_path(DWORD capacity, LPWSTR output) { + const auto path = fixture::root.wstring() + L"\\"; + if (path.size() >= capacity) return 0; + std::copy(path.begin(), path.end(), output); + output[path.size()] = L'\0'; + return static_cast(path.size()); +} +#define now_us fake_now_us +#define GetTempPathW test_get_temp_path +#include "../map_download.cpp" +#undef GetTempPathW +#undef now_us + +namespace dingosdk::https { +std::optional get_text(std::wstring_view, std::uint64_t, std::uint32_t, + const wchar_t*, Download* result, Watch* watch) { + fixture::require(watch && watch->deadline_us.load() == map_download::deadline.load(), + "metadata shares the session deadline"); + if (result) *result = {true, 200, 0}; + return R"({"namespace":"Team","name":"Map","community_listings":[{"community":"reskate","categories":["Maps"]}], + "latest":{"version_number":"2.0.0","description":"Test map"}})"; +} +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"); + archive = destination; + const auto request = ++downloads; + http_budgets.push_back(policy::remaining(watch->deadline_us.load(), fixture::clock.load())); + std::ofstream(destination, std::ios::binary) << "partial archive"; + watch->received.store(10); + watch->total.store(100); + downloading.store(true); + if (block_download) { + std::unique_lock lock(gate_mutex); + gate.wait(lock, [] { return release_download.load(); }); + } + if (slow) { + // Real download progress still cannot extend either absolute deadline. + fixture::clock.fetch_add(10 * 60 * policy::second_us); + watch->received.store(90); + } + observed_cancel.store(watch->cancel.load()); + if (watch->cancel.load()) return {false, 0, ERROR_CANCELLED}; + if (fallback && request == 1) { + fixture::clock.fetch_add(60 * policy::second_us); + return {false, 404, 0}; + } + if (transfer_error) return {false, 0, ERROR_WINHTTP_TIMEOUT}; + watch->received.store(100); + return {true, 200, 0}; +} +} + +namespace dingosdk::mods { +std::filesystem::path engine_data_root() noexcept { return fixture::root; } +ModList scan_mods(const std::filesystem::path&) noexcept { return {}; } +void save_mod_order(const std::filesystem::path&, const std::vector&) {} +} +namespace dingosdk::launcher_mods { +std::string install(const std::filesystem::path& root, const std::filesystem::path& source, bool, + const Progress& progress, const std::atomic& cancel, const InstallOptions& options) { + using namespace fixture; + require(fs::exists(source), "installer receives completed archive"); + ++installs; + installing.store(true); + if (block_install) { + std::unique_lock lock(gate_mutex); + gate.wait(lock, [] { return release_install.load(); }); + } + progress(0.5f); + installed_cancel.store(cancel.load()); + if (cancel.load()) throw std::runtime_error("cancelled"); + fs::create_directories(root / options.folder); + std::ofstream(root / options.folder / "reskate-levels.json") << "{}"; + return options.folder; +} +void remove(const std::filesystem::path&, const std::string&) {} +} +namespace dingosdk::live_mods { +bool only_maps(const std::filesystem::path&) { return true; } +bool busy() noexcept { return fixture::merging.load(); } +std::string apply(bool reload) { + fixture::require(!reload, "map apply must not reload prematurely"); + ++fixture::applies; + fixture::merging.store(true); + return "applying"; +} +Progress progress() { return {1, 2, "map"}; } +std::vector applied_mods() { return {"Team-Map"}; } +std::string status() { return "applied"; } +} +namespace dingosdk::multiplayer { +MultiplayerModel model() { return fixture::session; } +bool fetching_map() noexcept { return fixture::fetching.load(); } +void hold_map_load(bool hold) noexcept { fixture::held.store(hold); } +bool queue_command(std::string_view action, std::string_view, std::string_view) { + if (action == "stop") ++fixture::stops; + if (action == "rejoin") ++fixture::rejoins; + return true; +} +std::string map_package_name(std::string_view folder, std::string_view version) { + return std::string(folder) + "-" + std::string(version); +} +} +namespace dingosdk::overlay { +void set_map_download_icon(const std::string&) noexcept {} +void notify(NoticeLevel, std::string title, std::string text) noexcept { + std::lock_guard lock(fixture::messages_mutex); + fixture::notices.push_back(title + ": " + text); +} +} +namespace dingosdk::logging { +bool enabled(Level) noexcept { return false; } +void write(Level, Context, Channel, std::string_view) noexcept {} +void write(Level, Channel, std::string_view) noexcept {} +} + +namespace fixture { +namespace download = dingosdk::map_download; +using Stage = download::Stage; + +void drain() { + release_download.store(true); + release_install.store(true); + gate.notify_all(); + wait([] { return download::workers.load() == 0; }, "workers did not drain"); +} +void reset() { + drain(); + require(download::view().stage == Stage::idle, "previous lifecycle did not finish"); + fs::remove_all(root / "Mods"); + clock.store(1000000); + downloading.store(false); + installing.store(false); + release_download.store(false); + release_install.store(false); + merging.store(false); + fetching.store(true); + held.store(false); + observed_cancel.store(false); + installed_cancel.store(false); + downloads.store(0); + installs.store(0); + applies.store(0); + stops.store(0); + rejoins.store(0); + block_download = block_install = slow = transfer_error = fallback = false; + session = {}; + session.active = true; + session.connected = false; + notices.clear(); + http_budgets.clear(); + archive.clear(); + download::last_failed.clear(); +} +void offer(std::uint64_t deadline = 0, bool update = false) { + dingosdk::multiplayer::MapNeed need{"Team-Map-1.0.0", "Test map", true, false, + update ? "0.9.0" : "", deadline}; + download::offer(need); + wait([] { return download::asking(); }, "metadata did not reach confirmation"); + wait([] { return download::workers.load() == 0; }, "metadata worker did not drain"); +} +void start_apply() { + wait([] { return download::view().stage == Stage::applying; }, "did not install and reach applying"); + drain(); + download::tick(); + require(applies.load() == 1 && held.load(), "apply starts once and holds session loading"); +} +void applying() { + download::answer(true); + start_apply(); +} +void joined() { + merging.store(false); + download::tick(); + require(download::view().stage == Stage::joining, "completed merge advances to joining"); + fetching.store(false); + session.connected = true; + clock.fetch_add(2500000); + download::tick(); + require(download::view().stage == Stage::idle, "joined session clears lifecycle"); + require(!held.load() && stops.load() == 0 && rejoins.load() == 0, "normal join does not leave or rejoin"); +} +void idle(const char* reason) { + require(download::view().stage == Stage::idle, reason); + drain(); + require(stops.load() == 1, "failure leaves waiting session exactly once"); + download::tick(); + require(held.load() == (merging.load() && download::apply_started.load()), + "failure holds map loading only while an uncancellable live merge drains"); + require(archive.empty() || !fs::exists(archive), "partial archive removed"); +} + +void tests() { + reset(); + offer(); + applying(); + require(downloads.load() == 1 && installs.load() == 1, "success downloads and installs exactly once"); + joined(); + + reset(); + block_download = true; + slow = true; + offer(); + download::answer(true); + wait([] { return downloading.load(); }, "slow download did not start"); + clock.fetch_add(10 * 60 * policy::second_us); + download::watch.received.store(50); + download::tick(); + require(download::view().stage == Stage::downloading && download::view().received == 50 && held.load(), + "progressing transfer remains active beyond old five minute deadline"); + release_download.store(true); + gate.notify_all(); + start_apply(); + require(clock.load() > 5 * 60 * policy::second_us, "slow progressing transfer exceeds former five minute limit"); + joined(); + + reset(); + transfer_error = true; + offer(); + download::answer(true); + wait([] { return download::view().stage == Stage::idle; }, "HTTP timeout did not fail"); + idle("transfer timeout"); + require(installs.load() == 0 && noticed("timed out"), "HTTP timeout cannot install"); + transfer_error = false; + stops.store(0); + offer(); + applying(); + require(downloads.load() == 2, "timeout allows same package retry"); + joined(); + + reset(); + block_download = true; + offer(); + download::answer(true); + wait([] { return downloading.load(); }, "download did not start"); + clock.store(download::deadline.load()); + download::tick(); + idle("active session deadline"); + require(observed_cancel.load() && installs.load() == 0, "session expiry cancels active HTTP"); + + reset(); + block_install = true; + offer(); + download::answer(true); + 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(); + offer(); + applying(); + clock.store(download::deadline.load()); + download::tick(); + idle("apply timeout"); + require(merging.load() && rejoins.load() == 0, "late 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); + download::tick(); + require(rejoins.load() == 0 && !held.load(), "finished cancelled apply cannot rejoin and releases load hold"); + + reset(); + offer(); + download::answer(false); + idle("decline"); + require(downloads.load() == 0 && noticed("cancelled"), "decline never downloads"); + + reset(); + offer(); + clock.store(download::ask_until.load() - 1); + download::tick(); + require(download::asking(), "confirmation remains open one microsecond before deadline"); + clock.fetch_add(1); + download::answer(true); + idle("confirmation exact boundary"); + require(downloads.load() == 0 && noticed("two minutes"), "late click cannot bypass confirmation timeout"); + + reset(); + offer(); + clock.store(download::ask_until.load()); + download::tick(); + idle("confirmation tick timeout"); + require(downloads.load() == 0 && noticed("two minutes"), "tick expires unanswered prompt"); + + reset(); + offer(clock.load() + policy::completion_reserve_us); + 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); + download::tick(); + idle("last transfer microsecond"); + require(observed_cancel.load() && installs.load() == 0, "transfer exact boundary cancels without install"); + + reset(); + block_download = true; + offer(); + download::answer(true); + wait([] { return downloading.load(); }, "HTTP did not start for cancel race"); + download::answer(false); + require(download::watch.cancel.load(), "cancel flag is immediate"); + download::tick(); + require(held.load(), "cancelled HTTP keeps map loading held until worker drains"); + download::offer({"Team-Map-1.0.0", "Test map", true}); + require(downloads.load() == 1 && noticed("still finishing"), "retry cannot reuse a draining HTTP worker"); + drain(); + require(observed_cancel.load() && !fs::exists(archive) && installs.load() == 0, "cancel cleans archive without install"); + // The same package is retriable after a user cancellation. + block_download = false; + offer(); + applying(); + require(downloads.load() == 2, "retry fetches same package again"); + // Retry guard deliberately queued another stop above. + 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"); + joined(); + + reset(); + fallback = true; + offer(0, true); + download::answer(true); + wait([] { return download::view().stage == Stage::idle; }, "missing host version did not fail"); + idle("host version consistency"); + require(downloads.load() == 1 && installs.load() == 0, "version mismatch never falls back to another host version"); + + reset(); + offer(); + const auto original_deadline = download::deadline.load(); + download::offer({"Team-Other-1.0.0", "Other map", true}); + require(download::asking() && download::deadline.load() == original_deadline && stops.load() == 1, + "host changing maps does not replace in-flight request or extend its deadline"); + stops.store(0); + download::answer(false); + idle("host map change"); + + reset(); + download::tick(); + require(!held.load() && downloads.load() == 0 && stops.load() == 0 && rejoins.load() == 0, + "ordinary join without download is untouched"); +} +} + +int main(int argc, char** argv) { + try { + fixture::require(argc == 2, "pass a fixture directory"); + fixture::root = std::filesystem::absolute(argv[1]); + std::filesystem::create_directories(fixture::root); + fixture::tests(); + fixture::drain(); + std::filesystem::remove_all(fixture::root); + std::cout << "Production map download timeout lifecycle tests passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + // A bounded wait failure can leave detached production workers alive. Do not + // destroy their fixture globals while they may still be using them. + std::_Exit(1); + } +} diff --git a/Extension/Assets/map_download.cpp b/Extension/Assets/map_download.cpp index ade729e..757fc8a 100644 --- a/Extension/Assets/map_download.cpp +++ b/Extension/Assets/map_download.cpp @@ -6,9 +6,11 @@ #include "Engine/Vfs/mod_list.h" #include "Engine/Vfs/thunderstore_package.h" #include "Extension/Multiplayer/Net/protocol.h" +#include "Extension/Multiplayer/Session/monotonic_clock.h" #include "Extension/UI/Overlay/overlay.h" #include "Launcher/mod_manager.h" #include +#include #include #include #include @@ -23,13 +25,14 @@ namespace package = thunderstore_package; constexpr wchar_t agent[] = L"ReSkate"; constexpr char community[] = "reskate"; -// 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. +// Keep the existing package size restriction; time is bounded by the session policy. 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{}; multiplayer::MapNeed need; // under mutex, while not idle package::Name wanted; // under mutex std::string version; // under mutex: the one to download @@ -70,22 +73,47 @@ void leave_session() { } // Back to idle with the reason said, as the plain "map not installed" notice is. -void fail(std::string reason) { +void fail(std::string reason, bool retry = false) { std::string text; bool update{}; { std::lock_guard lock(mutex); + if (stage.exchange(Stage::idle) == Stage::idle) return; + watch.cancel.store(true); + stop_install.store(true); + apply_wanted.store(false); update = !need.have.empty(); text = "The " + host_word(need.server) + (need.moved ? " changed to " : " is on ") + need.map + (update ? ", at another version (" + std::string(multiplayer::map_package_version(need.package)) + ") than the one installed on this PC (" + need.have + "). " : ", which is not installed on this PC. ") + reason; - last_failed = need.package; + if (!retry) last_failed = need.package; } - stage.store(Stage::idle); leave_session(); logging::log(logging::Level::warning, logging::Channel::assets, "Map download: {}", text); overlay::notify(overlay::NoticeLevel::warning, update ? "Map version differs" : "Map not installed", std::move(text)); } + +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); + return false; + } + return !watch.cancel.load(); +} + +bool advance(Stage from, Stage to) { + if (!continuing()) return false; + return stage.compare_exchange_strong(from, to); +} + +void ask() { + ask_until.store(std::min(deadline.load(), multiplayer::now_us() + policy::confirmation_us)); + advance(Stage::checking, Stage::asking); +} // The packages of the maps installed here, by level (package_of): forgotten when one is installed. struct Known { std::string package; @@ -130,11 +158,12 @@ void check() { const auto bytes = read_bytes(mods_root() / name.folder() / L"icon.png", 4 * 1024 * 1024); overlay::set_map_download_icon(bytes); } catch (const std::exception&) {} - stage.store(Stage::asking); + ask(); return; } https::Download result; - const auto answer = https::get_text(package::details_url(name), 512 * 1024, 20, agent, &result); + const auto answer = https::get_text(package::details_url(name), 512 * 1024, 20, agent, &result, &watch); + if (!continuing()) return; if (!answer) return fail(result.http_status == 404 ? "Its mod is not on Thunderstore. Install its map mod yourself and join again." : "Thunderstore could not be reached to look for it."); @@ -151,9 +180,9 @@ void check() { } // Its icon for the card: small, and not worth holding the question up for long. if (!choice.icon.empty()) - if (const auto png = https::get_text(std::wstring(choice.icon.begin(), choice.icon.end()), 2 * 1024 * 1024, 8, agent)) + if (const auto png = https::get_text(std::wstring(choice.icon.begin(), choice.icon.end()), 2 * 1024 * 1024, 8, agent, nullptr, &watch)) overlay::set_map_download_icon(*png); - stage.store(Stage::asking); + ask(); } catch (const std::exception& failure) { fail(std::string("Looking for it failed: ") + failure.what()); } @@ -175,6 +204,7 @@ void fetch() { const auto root = mods_root(); const auto folder = name.folder(); try { + if (!continuing()) return; if (!off) { wchar_t temp[MAX_PATH + 1]{}; const auto length = GetTempPathW(MAX_PATH, temp); @@ -184,41 +214,57 @@ void fetch() { fs::remove(archive, error); watch.received.store(0); watch.total.store(0); - auto result = https::get(package::download_url(name, get), archive, largest_download, download_seconds, agent, &watch); + // One transfer budget across fallback requests; leave time for installation and joining. + const auto transfer = transfer_until.load(); + 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); + }; + auto result = download(get); // The host's version can be gone from Thunderstore: the newest is the same map. if (!update && !result.ok && result.http_status == 404 && get != fallback && !watch.cancel.load()) { fs::remove(archive, error); logging::log(logging::Level::info, logging::Channel::assets, "Map download: {} {} is not on Thunderstore any more; getting {}.", folder, get, fallback); - result = https::get(package::download_url(name, fallback), archive, largest_download, download_seconds, agent, &watch); + result = download(fallback); } if (!result.ok) { fs::remove(archive, error); - if (watch.cancel.load()) { stage.store(Stage::idle); leave_session(); return; } + 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); 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) + ")."); } - stage.store(Stage::installing); + if (!advance(Stage::downloading, Stage::installing)) { + fs::remove(archive, error); + return; + } try { // (In the installed one's place when it is the same map at another version. The // game may hold the old one's files open when it was skated this run: then it // cannot be replaced until the game is closed.) - launcher_mods::install(root, archive, update, [](float) {}, stop_install, {folder, name.owner, true}); + launcher_mods::install(root, archive, update, [](float) { continuing(); }, stop_install, {folder, name.owner, true}); } catch (const std::exception& failure) { fs::remove(archive, error); + if (!continuing()) return; return fail(update ? std::string("It could not be replaced while the game runs (") + failure.what() + "). Close the game, update it from the launcher and join again." : std::string("It could not be installed: ") + failure.what()); } fs::remove(archive, error); + if (!continuing()) return; { std::lock_guard known(known_mutex); known_packages.clear(); } } else { - stage.store(Stage::installing); + if (!advance(Stage::downloading, Stage::installing)) return; } + if (!continuing()) return; // A map and nothing else: this way in is for maps. std::error_code error; if (!fs::exists(root / folder / L"reskate-levels.json", error) || !live_mods::only_maps(root / folder)) { @@ -228,16 +274,18 @@ void fetch() { return fail("Its package holds more than a map, so it is not installed from here. Install it from the launcher if you trust it."); } if (off || update) { // (a map at another version may have been switched off: it is wanted now) + if (!continuing()) return; auto list = mods::scan_mods(mods::engine_data_root()); if (!list.issue.empty()) return fail("mods.json could not be read: " + list.issue); for (auto& entry : list.entries) if (same(entry.mod.name, folder)) entry.enabled = true; mods::save_mod_order(list.root, list.entries); } + if (!continuing()) return; logging::log(logging::Level::info, logging::Channel::assets, "Map download: {} is in Mods; applying it.", folder); apply_started.store(false); apply_wanted.store(true); - stage.store(Stage::applying); + if (!advance(Stage::installing, Stage::applying)) apply_wanted.store(false); } catch (const std::exception& failure) { fail(std::string("It could not be installed: ") + failure.what()); } @@ -245,10 +293,29 @@ void fetch() { // One piece of work at a time, which the stage sees to: each is started by the move into its // stage. Detached, as the live merge's thread is: the game may close while a map downloads. -void start(void (*work)()) { std::thread(work).detach(); } +void start(void (*work)()) { + ++workers; + try { + std::thread([work] { + struct Finished { ~Finished() { --workers; } } finished; + work(); + }).detach(); + } catch (...) { + --workers; + throw; + } +} } // namespace void offer(const multiplayer::MapNeed& asked) { + std::lock_guard lock(mutex); + // A cancelled worker/atomic live merge must drain before its globals or files are reused. + if (stage.load() == Stage::idle && (workers.load() || (apply_started.load() && live_mods::busy()))) { + leave_session(); + overlay::notify(overlay::NoticeLevel::warning, "Map download", + "The previous map operation is still finishing. Please try joining again shortly."); + return; + } auto idle = Stage::idle; const auto name = package::parse_name(asked.package); // (Taken before anything is set up, so a second need while this one is in hand is dropped.) @@ -256,10 +323,7 @@ void offer(const multiplayer::MapNeed& asked) { // Another map is in hand (the session moved on while it came). One at a time: the one // being fetched is finished, and the session, which now waits on this other one, is left. bool other{}; - { - std::lock_guard lock(mutex); - other = asked.package != need.package; - } + other = asked.package != need.package; if (other) { overlay::notify(overlay::NoticeLevel::warning, "Map changed again", "The " + host_word(asked.server) + " moved on to " + asked.map + " while another map was downloading. Join again when it is done."); @@ -268,7 +332,6 @@ void offer(const multiplayer::MapNeed& asked) { return; } { - std::lock_guard lock(mutex); need = asked; if (!name || asked.package == last_failed) { // (Said once already, or nothing this can fetch: the plain notice.) @@ -287,7 +350,14 @@ void offer(const multiplayer::MapNeed& asked) { } overlay::set_map_download_icon({}); watch.cancel.store(false); + 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); + watch.deadline_us.store(deadline.load()); stop_install.store(false); + apply_wanted.store(false); + apply_started.store(false); pick_download.store(true); start(check); } @@ -295,15 +365,32 @@ void offer(const multiplayer::MapNeed& asked) { void answer(bool download) noexcept { try { // (A click and a key can answer in the same moment: only the first does.) - auto asked = Stage::asking; - if (stage.compare_exchange_strong(asked, download ? Stage::downloading : Stage::idle)) { - if (download) start(fetch); - else leave_session(); - } else if (asked == Stage::downloading && !download) { - watch.cancel.store(true); + if (!continuing()) return; + const auto now = multiplayer::now_us(); + if (stage.load() == Stage::asking && policy::expired(ask_until.load(), now)) { + fail("The map download was not confirmed within two minutes. Please try joining again.", true); + return; + } + if (!download) { + 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); } } catch (...) { - stage.store(Stage::idle); + fail("The map download could not be started. Please try joining again.", true); } } @@ -345,10 +432,23 @@ View view() { } void tick() { - const auto now = stage.load(); + auto now = stage.load(); + if (now != Stage::idle) { + const auto session = multiplayer::model(); + if (!session.active && session.status.find("stopped waiting for the map download") != std::string::npos) + 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 // game already has, and would load from files that are being replaced). - multiplayer::hold_map_load(now == Stage::downloading || now == Stage::installing || now == Stage::applying); + multiplayer::hold_map_load((now != Stage::idle && now != Stage::joining) || + workers.load() || (apply_started.load() && live_mods::busy())); if (now == Stage::applying) { if (apply_wanted.load()) { // (A merge of the player's own may be running: wait for it and start this one.) @@ -371,24 +471,25 @@ void tick() { return fail("It is installed, but was left out when the mods were applied (" + live_mods::status() + ")."); // The level list takes the new map on the next ticks, and the session, which has been // trying the map all along, loads it then. - join_at.store(GetTickCount64()); - stage.store(Stage::joining); + join_at.store(multiplayer::now_us()); + advance(Stage::applying, Stage::joining); return; } if (now == Stage::joining) { - const auto waited = GetTickCount64() - join_at.load(); - if (waited < 2500) return; + const auto waited = multiplayer::now_us() - join_at.load(); + if (waited < 2500000) return; // Still waiting on it: the game has the mod and cannot find the map in it. - if (multiplayer::fetching_map()) { - if (waited > 30000) fail("It is installed, but the game still cannot load the map. Restart the game and join again."); + const auto session = multiplayer::model(); + if (multiplayer::fetching_map() || (session.active && !session.connected)) { + if (waited >= policy::installed_load_us) fail("It is installed, but the game still cannot load the map. Restart the game and join again."); return; } { // Should it not load after all, it is said and not offered round and round. std::lock_guard lock(mutex); - last_failed = need.package; + last_failed.clear(); } - stage.store(Stage::idle); + 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."); diff --git a/Extension/Assets/map_download.h b/Extension/Assets/map_download.h index 68f30ac..4cf7525 100644 --- a/Extension/Assets/map_download.h +++ b/Extension/Assets/map_download.h @@ -9,6 +9,9 @@ // 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. // // 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 new file mode 100644 index 0000000..5139b1e --- /dev/null +++ b/Extension/Multiplayer/Net/map_fetch_policy.h @@ -0,0 +1,27 @@ +#pragma once +#include +#include + +namespace dingosdk::multiplayer::map_fetch_policy { +inline constexpr std::uint64_t second_us = 1000000; +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; + +// All timestamps belong to one process's monotonic clock; none go on the wire. +constexpr bool expired(std::uint64_t deadline, std::uint64_t now) noexcept { + return deadline && now >= deadline; +} +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/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index 5739b9a..1127e51 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -1,4 +1,5 @@ #pragma once +#include "map_fetch_policy.h" #include "Extension/Multiplayer/Remote/cosmetics.h" #include "Extension/Multiplayer/Session/peer_slots.h" #include "Extension/Multiplayer/Session/object_state.h" @@ -207,9 +208,9 @@ bool valid_map_asset(std::string_view asset) noexcept; bool valid_map_pool(std::span pool, std::size_t maps) noexcept; // distinct indices below `maps` bool valid_map_label(std::string_view label) noexcept; // empty, or a name like a member's // How long a host goes on waiting for a player who says they are fetching the map -// (Packet::map_fetching), from the first time they say so: a large map on a slow line. Past -// it they are held to the time a load takes again. -inline constexpr std::uint64_t map_fetch_limit_us = 45ull * 60 * 1000000; +// (Packet::map_fetching), from the first time they say so, including installation and joining. +// Repeated requests never extend this limit. Each host uses its own monotonic clock. +inline constexpr std::uint64_t map_fetch_limit_us = map_fetch_policy::session_us; // The Thunderstore package a map comes from, so a player without it can be offered it: // "Owner-Name", or "Owner-Name-1.2.3" when the host knows its version. Empty for the game's own // maps and for a map mod that is not from Thunderstore. diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 368e276..d7e0588 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -155,6 +155,7 @@ void stop(Session &s, std::string reason) { s.map_label.clear(); s.map_package.clear(); s.fetching_since = 0; + s.map_fetch_deadline = 0; fetching_map_flag.store(false, std::memory_order_relaxed); s.world = 1; s.travelling = false; @@ -768,6 +769,7 @@ bool observe_local_world(Session &s, const NativeFrame &local, std::string_view } else if (s.travelling) { if (local.ready && !s.awaiting_map && s.host_world_ready) { s.travelling = false; + s.map_fetch_deadline = 0; s.last_world_ready = 0; for (auto &peer : active_peers(s)) { peer.last_packet = now; @@ -796,6 +798,10 @@ bool observe_local_world(Session &s, const NativeFrame &local, std::string_view // Native loading can unload every local actor. Keep the connection in preflight // until the selected map and a controllable skater are available. bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoader loader, std::uint64_t now) { + if (map_fetch_policy::expired(s.map_fetch_deadline, now)) { + stop(s, "Map download timed out. Please try joining the server again."); + return false; + } if (!s.awaiting_map) return true; // (While the map is being fetched the clock is held: see below.) @@ -844,16 +850,12 @@ bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoade if (!s.map_package.empty()) { if (!s.fetching_since) { s.fetching_since = now; + if (!s.map_fetch_deadline) s.map_fetch_deadline = now + map_fetch_limit_us; fetching_map_flag.store(true, std::memory_order_relaxed); - s.map_need = MapNeed{s.map_package, name, dedicated_host(s), s.travelling, have}; - s.rejoin = Session::Rejoin{{s.host_id, s.secret}, s.password}; + s.map_need = MapNeed{s.map_package, name, dedicated_host(s), s.travelling, have, s.map_fetch_deadline}; + s.rejoin = Session::Rejoin{{s.host_id, s.secret}, s.password, s.map_fetch_deadline}; s.last_map_request = s.last_world_ready = 0; // say so at once } - if (now - s.fetching_since > map_fetch_limit_us) { - s.leave_notice = where + ", which did not finish downloading in time. Install its map mod and join again."; - stop(s, s.leave_notice); - return false; - } // The time a load gets starts when the map is here. s.join_started = now; if (s.travelling) s.travel_started = now; diff --git a/Extension/Multiplayer/Session/session.h b/Extension/Multiplayer/Session/session.h index 9d7c314..6f4d4a0 100644 --- a/Extension/Multiplayer/Session/session.h +++ b/Extension/Multiplayer/Session/session.h @@ -39,6 +39,8 @@ struct MapNeed { // The version of it installed here, when that is another than the host's: the host's is // fetched in its place. Empty: the map is not installed. std::string have; + // Local steady-clock deadline, shared with the downloader, never transmitted. + std::uint64_t deadline_us{}; }; std::optional take_map_need(); // Whether a session is waiting on a map being fetched right now. Thread-safe. diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index 14f887d..9bd4fc7 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -968,6 +968,8 @@ std::string command(std::string_view action, std::string_view argument, std::str if (action == "rejoin") { rejoin = std::exchange(s.rejoin, std::nullopt); if (!rejoin) return "There is no session to go back to."; + if (map_fetch_policy::expired(rejoin->deadline_us, now_us())) + return "Map download timed out. Please try joining the server again."; } if (action != "host" && action != "join" && action != "echo" && !rejoin) return "Unknown multiplayer action."; @@ -1113,6 +1115,7 @@ std::string command(std::string_view action, std::string_view argument, std::str s.host_id = invitation->steam_id; s.awaiting_map = true; s.join_started = now_us(); + s.map_fetch_deadline = rejoin ? rejoin->deadline_us : 0; s.status = "Connecting and checking the host's map..."; } set_lobby_park_mode(s.mode == Mode::host || s.mode == Mode::join, s.mode == Mode::join); diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 0cfb6e4..5ed4b9c 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -261,11 +261,13 @@ struct Session { // The map is not installed and is being fetched (take_map_need): since when, 0 for not. // The host is told with what is sent while waiting (Packet::map_fetching). std::uint64_t fetching_since{}; + std::uint64_t map_fetch_deadline{}; std::optional map_need; // take_map_need() // The session a missing map ended, for "rejoin": who hosted it and the password key it was joined with. struct Rejoin { Invite invite; std::optional password; + std::uint64_t deadline_us{}; }; std::optional rejoin; std::string leave_notice; // take_leave_notice() @@ -480,7 +482,7 @@ inline void guest_fetching(Peer &peer, std::uint64_t now) { // (Only for a guest who is past the password.) if (!peer.handshaken && !peer.map_authorized) return; if (!peer.fetching_since) peer.fetching_since = now; - if (now - peer.fetching_since > map_fetch_limit_us) return; + if (map_fetch_policy::host_expired(peer.fetching_since, now)) return; if (!peer.handshaken) peer.connected_at = now; else if (peer.travel_since) peer.travel_since = now; } diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index 6fb3137..139c0f0 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -594,6 +594,12 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { continue; } const auto &p = *decoded; + if (s.mode == Mode::host && link->fetching_since && (!link->handshaken || link->travel_since) && + map_fetch_policy::host_expired(link->fetching_since, now)) { + disconnect(s, message.peer, "The server stopped waiting for the map download."); + if (s.mode == Mode::off) return; + continue; + } // A dedicated server sends no poses; its roster and control traffic keep it alive. if (dedicated_host(s) && message.peer == s.host_id) link->last_packet = now; if (p.kind == PacketKind::world_state) { @@ -608,6 +614,8 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { !newer_sequence(p.sequence, s.world_state_sequence))) continue; if (p.world > s.world) { + // Initial authorization during an automatic rejoin keeps the original budget. + const auto fetch_deadline = s.world ? 0 : s.map_fetch_deadline; clear_world(s, now); s.world = p.world; s.travelling = s.awaiting_map = s.join_map_authorized = true; @@ -618,6 +626,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { s.map_label.clear(); s.map_package.clear(); s.fetching_since = 0; + s.map_fetch_deadline = fetch_deadline; fetching_map_flag.store(false, std::memory_order_relaxed); s.map = 0; s.map_load_submitted = false; @@ -637,6 +646,8 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { s.host_world_ready = p.world_ready; link->world_ready = p.world_ready; link->last_packet = now; + if (s.mode == Mode::join && message.peer == s.host_id && !s.awaiting_map) + s.map_fetch_deadline = 0; if (s.travelling) s.status = p.world_ready ? "Host map ready. Waiting for local loading..." : "The host is changing maps. Your session stays connected."; @@ -1134,6 +1145,12 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { } // Authorized arrivals get time for native loading; this has an absolute // deadline, so requests cannot keep an unused slot reserved indefinitely. + if (s.mode == Mode::host && p.fetching_since && (!p.handshaken || p.travel_since) && + map_fetch_policy::host_expired(p.fetching_since, now)) { + disconnect(s, p.member.id, "The server stopped waiting for the map download."); + if (s.mode == Mode::off) return; + continue; + } const std::uint64_t handshake_timeout = p.map_authorized ? 180000000 : 8000000; if (p.handshaken && (s.travelling || p.travel_since)) { if (p.travel_since && now - p.travel_since > 180000000) { diff --git a/Extension/Multiplayer/Test/map_fetch_policy_tests.cpp b/Extension/Multiplayer/Test/map_fetch_policy_tests.cpp new file mode 100644 index 0000000..6f998eb --- /dev/null +++ b/Extension/Multiplayer/Test/map_fetch_policy_tests.cpp @@ -0,0 +1,86 @@ +#include "Extension/Multiplayer/Net/map_fetch_policy.h" +#include +#include +#include +#include +#include + +namespace policy = dingosdk::multiplayer::map_fetch_policy; + +void require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +int main() { + try { + require(policy::second_us == 1000000, "microsecond units"); + require(policy::session_us == 1800 * policy::second_us, "30 minute session"); + 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"); + require(policy::remaining(0, 100) == 0, "zero remaining saturates"); + + // 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}) { + const auto deadline = origin + duration; + require(!policy::expired(deadline, deadline - 1), "before boundary"); + require(policy::expired(deadline, deadline), "exact boundary"); + require(policy::expired(deadline, deadline + 1), "after boundary"); + require(policy::remaining(deadline, deadline - 1) == 1, "last microsecond"); + require(policy::remaining(deadline, deadline) == 0, "exact remaining"); + require(policy::remaining(deadline, deadline + 1) == 0, "remaining never underflows"); + } + const auto overall = origin + policy::session_us; + require(!policy::host_expired(origin, origin - 1), "host rejects earlier clock"); + require(!policy::host_expired(origin, overall - 1), "host before boundary"); + require(policy::host_expired(origin, overall), "host exact boundary"); + 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; + for (const auto elapsed : std::array{ + policy::session_us - 1, policy::session_us, policy::session_us + 1}) { + require(policy::host_expired(host_clock, host_clock + elapsed) == + policy::expired(client_clock + policy::session_us, client_clock + elapsed), + "independent host/client epochs yield the same expiration"); + } + std::cout << "Map fetch policy boundary tests passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/Launcher/mod_manager.cpp b/Launcher/mod_manager.cpp index 499d750..aee3c34 100644 --- a/Launcher/mod_manager.cpp +++ b/Launcher/mod_manager.cpp @@ -291,6 +291,7 @@ std::string install_zip(const fs::path& mods, const fs::path& source, bool repla std::vector files; const auto count = mz_zip_reader_get_num_files(&zip.archive); for (mz_uint index = 0; index < count; ++index) { + if (cancel) fail("Install cancelled."); mz_zip_archive_file_stat stat{}; if (!mz_zip_reader_file_stat(&zip.archive, index, &stat)) fail("The .zip file is damaged."); const auto name = safe_entry(stat.m_filename); @@ -328,6 +329,9 @@ std::string install_zip(const fs::path& mods, const fs::path& source, bool repla if (!ok) fail("Could not extract " + relative + " from the .zip file."); } finish(staging.path(), *root, utf8(source.filename().wstring()), options); + // Cancellation is safe before replacing the installed folder, not midway through commit. + if (progress) progress(1.f); + if (cancel) fail("Install cancelled."); commit(mods, staging, name, replace); return name; } @@ -337,6 +341,7 @@ std::string install_folder(const fs::path& mods, const fs::path& source, bool re std::vector files; std::vector paths; for (auto it = fs::recursive_directory_iterator(source); it != fs::recursive_directory_iterator(); ++it) { + if (cancel) fail("Install cancelled."); if (!it->is_regular_file()) continue; paths.push_back(it->path()); auto relative = utf8(fs::relative(it->path(), source).generic_wstring()); @@ -363,6 +368,8 @@ std::string install_folder(const fs::path& mods, const fs::path& source, bool re if (progress && total) progress(static_cast(written) / static_cast(total)); } finish(staging.path(), *root, utf8(source.filename().wstring()), options); + if (progress) progress(1.f); + if (cancel) fail("Install cancelled."); commit(mods, staging, name, replace); return name; } diff --git a/README.md b/README.md index 83041f6..5639d99 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,21 @@ a public server and hosting a lobby. Steam Deck should work the same way but is custom map and then join a server on another map, the screen can stay black ([#31](https://github.com/Dingo-Shenanigans/ReSkate/issues/31)): load the server's map first. +### 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. + +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. + ### Controls | Key | Opens | diff --git a/Server/server_host.cpp b/Server/server_host.cpp index 76b974f..02eda5a 100644 --- a/Server/server_host.cpp +++ b/Server/server_host.cpp @@ -211,7 +211,7 @@ void Host::fetching(Guest &guest) { guest.fetching_since = now_; log_("[map] " + guest_name(guest) + " is downloading the map"); } - if (now_ - guest.fetching_since > map_fetch_limit_us) return; + if (map_fetch_policy::host_expired(guest.fetching_since, now_)) return; if (!guest.handshaken) guest.connected_at = now_; else if (guest.travel_since) guest.travel_since = now_; } @@ -1093,6 +1093,9 @@ void Host::receive(std::uint64_t peer, std::span bytes, std: if (!decoded || decoded->session != secret_ || decoded->source != peer) return drop(peer, "Join code, sender identity, or multiplayer protocol did not match."); const auto &p = *decoded; + if (link->fetching_since && (!link->handshaken || link->travel_since) && + map_fetch_policy::host_expired(link->fetching_since, now_)) + return drop(peer, "The server stopped waiting for the map download."); link->traffic.total.in[traffic_kind(p.kind)] += bytes.size(); traffic_.total.in[traffic_kind(p.kind)] += bytes.size(); switch (p.kind) { @@ -1790,6 +1793,11 @@ void Host::tick(std::uint64_t now) { auto &g = *(it++)->second; // Authorized arrivals get time for loading; this deadline is absolute, so // requests cannot hold an unused slot indefinitely. + if (g.fetching_since && (!g.handshaken || g.travel_since) && + map_fetch_policy::host_expired(g.fetching_since, now_)) { + drop(g.member.id, "The server stopped waiting for the map download."); + continue; + } const std::uint64_t handshake_timeout = g.map_authorized ? 180000000 : 8000000; if (g.handshaken && g.travel_since) { if (now_ - g.travel_since > 180000000) drop(g.member.id, "Could not finish loading the new map.");