diff --git a/CMakeLists.txt b/CMakeLists.txt index 8e8dc72..b9730ce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -62,7 +62,21 @@ 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_https_download_timeout_tests Engine/Vfs/Test/https_download_timeout_tests.cpp) + target_include_directories(dingosdk_https_download_timeout_tests PRIVATE "${PROJECT_SOURCE_DIR}") + target_link_libraries(dingosdk_https_download_timeout_tests PRIVATE winhttp) + add_test(NAME https_download_timeout COMMAND dingosdk_https_download_timeout_tests + "${CMAKE_CURRENT_BINARY_DIR}/https-download-fixtures") + 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/Test/https_download_timeout_tests.cpp b/Engine/Vfs/Test/https_download_timeout_tests.cpp new file mode 100644 index 0000000..81acd50 --- /dev/null +++ b/Engine/Vfs/Test/https_download_timeout_tests.cpp @@ -0,0 +1,334 @@ +#include +#include +#include "../https_download.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fake_http { +enum class Stage { none, send, receive, read, eof, close }; +struct Handle { + DWORD_PTR context{}; + WINHTTP_STATUS_CALLBACK callback{}; + DWORD notifications{}; + bool closed{}; +}; +struct State { + dingosdk::https::Watch* watch{}; + Stage stop_at{Stage::none}, fail_at{Stage::none}; + bool timeout{}, callback_error{}, content_length{true}, downgrade{}; + unsigned opens{}, reads{}, closing_callbacks{}, pending_buffer_closes{}; + DWORD redirect{}, request_flags{}, session_flags{}; + void* pending_buffer{}; + Handle session, connection, request; + std::string body{"payload"}; +}; +State state; + +void stop() { + if (state.timeout) state.watch->deadline_us.store(1); + else state.watch->cancel.store(true); +} +void notify(DWORD status, void* info = nullptr, DWORD size = 0) { + state.request.callback(&state.request, state.request.context, status, info, size); +} +bool pending(Stage stage) { + if (state.stop_at != stage) return false; + stop(); + return true; +} +bool failure(Stage stage) { + if (state.fail_at != stage) return false; + if (state.callback_error) { + WINHTTP_ASYNC_RESULT error{}; + error.dwError = ERROR_WINHTTP_CONNECTION_ERROR; + notify(WINHTTP_CALLBACK_STATUS_REQUEST_ERROR, &error, sizeof(error)); + } else { + SetLastError(ERROR_WINHTTP_CONNECTION_ERROR); + } + return true; +} +HINTERNET WINAPI open(LPCWSTR, DWORD, LPCWSTR, LPCWSTR, DWORD flags) { + ++state.opens; + state.session_flags = flags; + return &state.session; +} +HINTERNET WINAPI connect(HINTERNET, LPCWSTR, INTERNET_PORT, DWORD) { + return &state.connection; +} +HINTERNET WINAPI open_request(HINTERNET, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR*, DWORD flags) { + state.request_flags = flags; + return &state.request; +} +BOOL WINAPI set_timeouts(HINTERNET, int, int, int, int) { return TRUE; } +BOOL WINAPI set_option(HINTERNET handle, DWORD option, LPVOID value, DWORD) { + if (option == WINHTTP_OPTION_CONTEXT_VALUE) + static_cast(handle)->context = *static_cast(value); + if (option == WINHTTP_OPTION_REDIRECT_POLICY) state.redirect = *static_cast(value); + return TRUE; +} +WINHTTP_STATUS_CALLBACK WINAPI set_callback(HINTERNET handle, WINHTTP_STATUS_CALLBACK callback, + DWORD notifications, DWORD_PTR) { + auto& request = *static_cast(handle); + const auto previous = request.callback; + request.callback = callback; + request.notifications = notifications; + return previous; +} +BOOL WINAPI send(HINTERNET, LPCWSTR, DWORD, LPVOID, DWORD, DWORD, DWORD_PTR context) { + state.request.context = context; + if (pending(Stage::send)) return TRUE; + if (failure(Stage::send)) return state.callback_error ? TRUE : FALSE; + notify(WINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE); + return TRUE; +} +BOOL WINAPI receive(HINTERNET, LPVOID) { + if (pending(Stage::receive)) return TRUE; + if (failure(Stage::receive)) return state.callback_error ? TRUE : FALSE; + if (state.downgrade && state.redirect == WINHTTP_OPTION_REDIRECT_POLICY_DISALLOW_HTTPS_TO_HTTP) { + SetLastError(ERROR_WINHTTP_REDIRECT_FAILED); + return FALSE; + } + notify(WINHTTP_CALLBACK_STATUS_HEADERS_AVAILABLE); + return TRUE; +} +BOOL WINAPI query(HINTERNET, DWORD query, LPCWSTR, LPVOID value, LPDWORD size, LPDWORD) { + if (query == (WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER)) { + *static_cast(value) = 200; + } else if (query == (WINHTTP_QUERY_CONTENT_LENGTH | WINHTTP_QUERY_FLAG_NUMBER) && state.content_length) { + *static_cast(value) = static_cast(state.body.size()); + } else { + SetLastError(ERROR_WINHTTP_HEADER_NOT_FOUND); + return FALSE; + } + *size = sizeof(DWORD); + return TRUE; +} +BOOL WINAPI read(HINTERNET, LPVOID buffer, DWORD capacity, LPDWORD count) { + // The production async path must receive the count through its callback. + if (count || capacity < state.body.size()) { + SetLastError(ERROR_INVALID_PARAMETER); + return FALSE; + } + if (++state.reads == 1) { + std::memcpy(buffer, state.body.data(), state.body.size()); + notify(WINHTTP_CALLBACK_STATUS_READ_COMPLETE, buffer, static_cast(state.body.size())); + } else { + if (state.stop_at == Stage::read) { + state.pending_buffer = buffer; + stop(); + return TRUE; + } + if (failure(Stage::read)) return state.callback_error ? TRUE : FALSE; + if (state.stop_at == Stage::eof) stop(); + notify(WINHTTP_CALLBACK_STATUS_READ_COMPLETE, buffer, 0); + } + return TRUE; +} +BOOL WINAPI close(HINTERNET handle) { + auto& object = *static_cast(handle); + if (object.closed) { + SetLastError(ERROR_INVALID_HANDLE); + return FALSE; + } + object.closed = true; + if (handle == &state.request && object.callback) { + if (state.pending_buffer) { + // Pending I/O can still touch the caller's buffer during closure. + *static_cast(state.pending_buffer) = 'x'; + ++state.pending_buffer_closes; + state.pending_buffer = nullptr; + } + if (state.stop_at == Stage::close) stop(); + if (object.context && (object.notifications & WINHTTP_CALLBACK_FLAG_HANDLES)) { + ++state.closing_callbacks; + notify(WINHTTP_CALLBACK_STATUS_HANDLE_CLOSING); + } + } + return TRUE; +} +} + +#define WinHttpOpen fake_http::open +#define WinHttpConnect fake_http::connect +#define WinHttpOpenRequest fake_http::open_request +#define WinHttpSetTimeouts fake_http::set_timeouts +#define WinHttpSetOption fake_http::set_option +#define WinHttpSetStatusCallback fake_http::set_callback +#define WinHttpSendRequest fake_http::send +#define WinHttpReceiveResponse fake_http::receive +#define WinHttpQueryHeaders fake_http::query +#define WinHttpReadData fake_http::read +#define WinHttpCloseHandle fake_http::close +#include "../https_download.cpp" +#undef WinHttpOpen +#undef WinHttpConnect +#undef WinHttpOpenRequest +#undef WinHttpSetTimeouts +#undef WinHttpSetOption +#undef WinHttpSetStatusCallback +#undef WinHttpSendRequest +#undef WinHttpReceiveResponse +#undef WinHttpQueryHeaders +#undef WinHttpReadData +#undef WinHttpCloseHandle + +namespace { +int failures{}; +void check(bool condition, const char* what) { + if (condition) return; + std::cerr << "FAIL: " << what << '\n'; + ++failures; +} +std::string contents(const std::filesystem::path& path) { + std::ifstream input(path, std::ios::binary); + return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; +} +struct Fixtures { + std::filesystem::path root; + bool created; + explicit Fixtures(std::filesystem::path path) : root(std::move(path)), + created(std::filesystem::create_directory(root)) {} + ~Fixtures() { + if (created) { + std::error_code error; + std::filesystem::remove_all(root, error); + } + } +}; +void reset(dingosdk::https::Watch& watch) { + fake_http::state = {}; + fake_http::state.watch = &watch; + watch.cancel.store(false); + watch.deadline_us.store(0); + watch.received.store(0); + watch.total.store(0); +} +} + +int main(int argc, char** argv) { + using namespace dingosdk::https; + using fake_http::Stage; + Fixtures fixtures(argc > 1 ? std::filesystem::path(argv[1]) : std::filesystem::current_path() / + ("https-download-timeout-fixtures-" + std::to_string(GetCurrentProcessId()))); + if (!fixtures.created) { + std::cerr << "Fixture directory already exists; refusing to overwrite it\n"; + return 1; + } + const auto path = fixtures.root / "download.bin"; + Watch watch; + const auto get = [&](std::uint64_t limit = 1024, std::uint32_t seconds = 5) { + return dingosdk::https::get(L"https://example.invalid/file", path, limit, seconds, L"test", &watch); + }; + const auto removed = [&] { return !std::filesystem::exists(path); }; + const auto closed = [&] { + return fake_http::state.closing_callbacks == 1 && fake_http::state.request.closed && + fake_http::state.connection.closed && fake_http::state.session.closed; + }; + + reset(watch); + auto result = get(); + check(result.ok && result.http_status == 200 && result.error == 0, "actual GET succeeds"); + check(contents(path) == "payload" && watch.received.load() == 7 && watch.total.load() == 7, + "successful bytes and progress are retained"); + check(closed(), "successful request receives final closing callback"); + check(fake_http::state.session_flags & WINHTTP_FLAG_ASYNC, "session uses asynchronous WinHTTP"); + check(fake_http::state.request_flags & WINHTTP_FLAG_SECURE, "request stays HTTPS"); + check(fake_http::state.redirect == WINHTTP_OPTION_REDIRECT_POLICY_DISALLOW_HTTPS_TO_HTTP, + "HTTPS downgrade redirect policy is configured"); + std::filesystem::remove(path); + + for (const auto stage : {Stage::send, Stage::receive, Stage::read, Stage::eof, Stage::close}) { + for (const bool timeout : {false, true}) { + reset(watch); + fake_http::state.stop_at = stage; + fake_http::state.timeout = timeout; + result = get(); + check(!result.ok && result.error == (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"); + if (stage == Stage::read) + check(watch.received.load() == 7 && fake_http::state.pending_buffer_closes == 1, + "partial output is cleaned while pending read buffer survives close"); + } + } + + for (const auto stage : {Stage::send, Stage::receive, Stage::read}) { + for (const bool callback_error : {false, true}) { + reset(watch); + fake_http::state.fail_at = stage; + fake_http::state.callback_error = callback_error; + result = get(); + check(!result.ok && result.error == ERROR_WINHTTP_CONNECTION_ERROR, + "immediate and callback API errors are reported"); + check(removed() && closed(), "API errors clean up output and close registered request"); + } + } + + reset(watch); + watch.deadline_us.store(1); + result = get(); + check(!result.ok && result.error == ERROR_TIMEOUT && fake_http::state.opens == 0 && removed(), + "already expired external deadline prevents network and output creation"); + reset(watch); + watch.deadline_us.store(static_cast(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()) + 60000000); + result = get(1024, 0); + check(!result.ok && result.error == ERROR_TIMEOUT && fake_http::state.opens == 0, + "internal timeout wins over a later external steady-clock deadline"); + reset(watch); + watch.cancel.store(true); + watch.deadline_us.store(1); + result = get(); + check(!result.ok && result.error == ERROR_CANCELLED && fake_http::state.opens == 0, + "explicit cancellation wins when both stop conditions hold"); + + reset(watch); + { + std::ofstream output(path, std::ios::binary); + output << "existing destination"; + } + result = get(); + check(!result.ok && contents(path) == "existing destination", + "CREATE_NEW failure preserves a pre-existing destination"); + check(closed(), "existing-destination failure still closes the request"); + std::filesystem::remove(path); + + for (const bool content_length : {false, true}) { + reset(watch); + fake_http::state.content_length = content_length; + result = get(3); + check(!result.ok && result.error == ERROR_FILE_TOO_LARGE && removed() && closed(), + "announced and streamed oversized bodies remain rejected and cleaned up"); + } + reset(watch); + fake_http::state.downgrade = true; + result = get(); + check(!result.ok && result.error == ERROR_WINHTTP_REDIRECT_FAILED && removed() && closed(), + "HTTPS-to-HTTP redirects remain refused"); + reset(watch); + fake_http::state.body.clear(); + result = get(); + check(!result.ok && result.error == ERROR_BAD_LENGTH && removed() && closed(), + "empty body remains rejected and its created output is removed"); + + reset(watch); + fake_http::state.stop_at = Stage::send; + const auto text = get_text(L"https://example.invalid/metadata", 1024, 5, L"test", &result, &watch); + check(!text && !result.ok && result.error == ERROR_CANCELLED && closed(), + "get_text forwards its Watch into the actual asynchronous GET"); + + if (failures) return 1; + std::cout << "HTTPS deadline/cancellation tests passed\n"; + return 0; +} diff --git a/Engine/Vfs/https_download.cpp b/Engine/Vfs/https_download.cpp index 4ea1975..6ccb6b2 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,30 @@ 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); +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; 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..e20fbdf --- /dev/null +++ b/Extension/Assets/Test/map_download_timeout_tests.cpp @@ -0,0 +1,650 @@ +#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}; +constexpr auto use_download_deadline = static_cast(-1); +std::atomic active_deadline{use_download_deadline}; +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{}; +std::atomic queued_stop_deadline{}; +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(); } +std::uint64_t map_fetch_deadline() noexcept { + const auto active = fixture::active_deadline.load(); + return active == fixture::use_download_deadline ? map_download::deadline.load() : active; +} +void hold_map_load(bool hold, std::string_view package) noexcept { + fixture::held.store(hold); + if (hold && package != "Team-Map-1.0.0") std::abort(); +} +bool queue_command(std::string_view action, std::string_view argument, std::string_view password) { + if (action == "stop") ++fixture::stops; + if (action == "stop-map-fetch") { + const auto expected = map_download::deadline.load(); + fixture::require(expected && argument == std::to_string(expected) && password.empty(), + "map stop carries the captured originating attempt deadline"); + fixture::queued_stop_deadline.store(expected); + ++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); + active_deadline.store(use_download_deadline); + 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); + queued_stop_deadline.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, unsigned expected_stops = 1) { + require(download::view().stage == Stage::idle, reason); + drain(); + require(stops.load() == expected_stops, "failure only stops its originating waiting session"); + if (expected_stops) + require(queued_stop_deadline.load() == download::deadline.load(), + "queued map stop identifies the originating attempt for dispatch-time ownership validation"); + 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(); + merging.store(true); + offer(); + download::answer(true); + wait([] { return download::view().stage == Stage::applying; }, "unrelated live merge blocked map offer"); + drain(); + download::tick(); + require(applies.load() == 0 && held.load(), "map apply waits for unrelated live merge without rejecting offer"); + merging.store(false); + download::tick(); + require(applies.load() == 1, "map apply starts after unrelated merge drains"); + joined(); + + reset(); + block_download = true; + 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"); + release_download.store(true); + gate.notify_all(); + start_apply(); + 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(); + 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"); + + 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(); + const auto short_session = clock.load() + policy::confirmation_us / 2; + offer(short_session); + require(download::ask_until.load() == short_session, "confirmation is capped by existing session deadline"); + clock.store(short_session); + download::answer(true); + idle("shorter session confirmation boundary"); + require(downloads.load() == 0 && noticed("timed out"), "confirmation never extends existing session budget"); + + 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"); + // The original user cancellation stopped its waiting attempt. + 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(); + block_download = true; + offer(); + download::answer(true); + wait([] { return downloading.load(); }, "download did not start for session replacement"); + active_deadline.store(0); + download::tick(); + idle("session identity changed", 0); + require(observed_cancel.load() && installs.load() == 0, + "download cancels when the active session no longer owns its deadline"); + + reset(); + offer(); + active_deadline.store(0); + fetching.store(false); + session.connected = true; + download::tick(); + require(download::view().stage == Stage::idle && !held.load() && downloads.load() == 0 && + stops.load() == 0 && rejoins.load() == 0, + "abandoned confirmation cancels without holding or stopping a fresh unrelated ordinary join"); + download::answer(true); + require(downloads.load() == 0, "late confirmation cannot start download for abandoned session"); + + reset(); + offer(); + const auto abandoned_deadline = download::deadline.load(); + clock.fetch_add(policy::second_us); + const auto fresh_deadline = clock.load() + policy::session_us; + active_deadline.store(fresh_deadline); + 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, + "fresh same-package attempt replaces stale confirmation without stopping its new session"); + applying(); + joined(); + + reset(); + block_download = true; + offer(); + download::answer(true); + wait([] { return downloading.load(); }, "old download did not start for queued replacement"); + clock.fetch_add(policy::second_us); + const auto queued_deadline = clock.load() + policy::session_us; + active_deadline.store(queued_deadline); + download::offer({"Team-Map-1.0.0", "Test map", true, false, "", queued_deadline}); + download::tick(); + require(download::view().stage == Stage::idle && download::watch.cancel.load() && + downloads.load() == 1 && stops.load() == 0 && download::pending && + download::pending->deadline_us == queued_deadline, + "fresh offer queues while cancelled worker drains without stopping replacement session"); + drain(); + require(observed_cancel.load() && installs.load() == 0 && !fs::exists(archive), + "old worker cleans partial archive before queued replacement can use its globals"); + block_download = false; + 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"); + applying(); + require(downloads.load() == 2, "queued fresh attempt starts exactly one new transfer"); + joined(); + + reset(); + block_download = true; + offer(); + download::answer(true); + wait([] { return downloading.load(); }, "old download did not start for stale pending need"); + clock.fetch_add(policy::second_us); + const auto superseded_deadline = clock.load() + policy::session_us; + active_deadline.store(superseded_deadline); + download::offer({"Team-Map-1.0.0", "Test map", true, false, "", superseded_deadline}); + active_deadline.store(0); + drain(); + download::tick(); + require(download::view().stage == Stage::idle && !download::pending && downloads.load() == 1 && + stops.load() == 0 && !held.load(), + "queued need is discarded when its replacement session also ends before worker drain"); + + reset(); + offer(); + applying(); + merging.store(false); + download::tick(); + clock.store(download::join_at.load() + policy::installed_load_us - 1); + download::tick(); + require(download::view().stage == Stage::joining, "installed map load retains its last microsecond"); + clock.fetch_add(1); + download::tick(); + idle("installed map load timeout"); + require(noticed("still cannot load"), "installed map reports load failure at 30 seconds"); + + reset(); + offer(); + applying(); + 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() == 1 && stops.load() == 0, + "successful install rejoins when the original session ended independently"); + + reset(); + offer(); + applying(); + clock.store(download::deadline.load() - 2); + merging.store(false); + download::tick(); + require(download::view().stage == Stage::joining, "near-deadline apply reaches joining"); + clock.fetch_add(1); + session.connected = true; + fetching.store(false); + active_deadline.store(0); + clock.fetch_add(2); + download::tick(); + require(download::view().stage == Stage::idle && !held.load() && stops.load() == 0 && + rejoins.load() == 0 && !noticed("timed out"), + "join completed within session budget retires successfully even when next tick is after deadline"); + + 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..2d37f8b 100644 --- a/Extension/Assets/map_download.cpp +++ b/Extension/Assets/map_download.cpp @@ -6,15 +6,18 @@ #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 #include #include #include +#include namespace dingosdk::map_download { namespace { @@ -23,14 +26,16 @@ 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 +std::optional pending; // a fresh attempt waiting for cancelled work to drain package::Name wanted; // under mutex std::string version; // under mutex: the one to download std::string newest; // under mutex @@ -65,27 +70,56 @@ bool need_server() { } // The session that is waiting on the map is left: there is no map to wait for. -void leave_session() { - if (multiplayer::fetching_map()) multiplayer::queue_command("stop", "", ""); +void leave_session(std::uint64_t expected = 0) { + const auto owned = expected ? expected : multiplayer::map_fetch_deadline(); + if (multiplayer::fetching_map() && owned) + multiplayer::queue_command("stop-map-fetch", std::to_string(owned), ""); } // 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::uint64_t failed_deadline{}; { std::lock_guard lock(mutex); + if (stage.exchange(Stage::idle) == Stage::idle) return; + failed_deadline = deadline.load(); + 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(); + if (multiplayer::map_fetch_deadline() == failed_deadline) leave_session(failed_deadline); 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 +164,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 +186,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 +210,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 +220,61 @@ 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 (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; + } 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 +284,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 +303,35 @@ 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::unique_lock lock(mutex); + if (stage.load() != Stage::idle && asked.deadline_us && asked.deadline_us != deadline.load()) { + lock.unlock(); + fail("Map download cancelled.", true); + lock.lock(); + } + // 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()))) { + pending = asked; + overlay::notify(overlay::NoticeLevel::warning, "Map download", + "The previous map operation is still finishing. Waiting before starting this map."); + return; + } + pending.reset(); 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 +339,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 +348,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,23 +366,52 @@ 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); + try { + start(check); + } catch (...) { + lock.unlock(); + fail("The map download could not be started. Please try joining again.", true); + } } 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 +453,42 @@ View view() { } void tick() { - const auto now = stage.load(); + std::optional next; + { + std::lock_guard lock(mutex); + if (pending && pending->deadline_us != multiplayer::map_fetch_deadline()) pending.reset(); + if (pending && stage.load() == Stage::idle && !workers.load() && + !(apply_started.load() && live_mods::busy())) + next = std::exchange(pending, std::nullopt); + } + if (next) offer(*next); + auto now = stage.load(); + if (now != Stage::idle) { + const auto session = multiplayer::model(); + // Admission (or completed travel) retires the session deadline. Do not turn an + // already successful join into a timeout while waiting out the UI's settling delay. + if (now == Stage::joining && session.active && session.connected && + !multiplayer::fetching_map() && !multiplayer::map_fetch_deadline()) { + std::lock_guard lock(mutex); + 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()) + fail("Map download cancelled.", true); + 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()), need_package()); 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 +511,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 daf0fa2..2a74fa9 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" @@ -226,9 +227,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 d166bf4..1ad2a03 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -158,6 +158,8 @@ void stop(Session &s, std::string reason) { s.map_label.clear(); s.map_package.clear(); s.fetching_since = 0; + s.map_fetch_deadline = 0; + map_fetch_deadline_flag.store(0); fetching_map_flag.store(false, std::memory_order_relaxed); s.world = 1; s.travelling = false; @@ -734,7 +736,10 @@ std::string take_removed_notice() { return std::exchange(session().removed_notic std::optional take_map_need() { return std::exchange(session().map_need, std::nullopt); } void set_map_package_lookup(MapPackageLookup lookup) noexcept { map_package_lookup.store(lookup, std::memory_order_relaxed); } bool fetching_map() noexcept { return fetching_map_flag.load(std::memory_order_relaxed); } -void hold_map_load(bool hold) noexcept { map_load_held.store(hold, std::memory_order_relaxed); } +std::uint64_t map_fetch_deadline() noexcept { return map_fetch_deadline_flag.load(); } +void hold_map_load(bool hold, std::string_view package) noexcept { + map_load_held.store(hold && !package.empty() ? map_hash(package) : 0); +} MultiplayerChat chat() { auto &s = session(); std::lock_guard lock(s.mutex); @@ -773,6 +778,8 @@ 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; + map_fetch_deadline_flag.store(0); s.last_world_ready = 0; for (auto &peer : active_peers(s)) { peer.last_packet = now; @@ -801,6 +808,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.) @@ -836,7 +847,7 @@ bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoade // installed one's place), and loads when the two agree. const auto have = std::string(other_map_version(s.map_package, host_map_package(s.join_destination))); // (Nor while its files are being put in place and applied: hold_map_load.) - const bool held = map_load_held.load(std::memory_order_relaxed); + const bool held = !s.map_package.empty() && map_load_held.load() == map_hash(s.map_package); const auto result = have.empty() && !held ? loader(s.join_destination, s.map_load_submitted, detail) : MapLoadResult::missing; if (result == MapLoadResult::missing) { const auto name = s.map_label.empty() ? world_level_name(world_destination_asset(s.join_destination)) : s.map_label; @@ -849,16 +860,13 @@ 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; + map_fetch_deadline_flag.store(s.map_fetch_deadline); 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 bc0eeaa..21e00cd 100644 --- a/Extension/Multiplayer/Session/session.h +++ b/Extension/Multiplayer/Session/session.h @@ -31,7 +31,8 @@ std::string take_removed_notice(); // for download instead (Extension/Assets/map_download.h): the session does not end. It tells // the host the map is being fetched, which is given much longer than a load, and goes on // trying the map, so it loads as soon as it is installed. Once per map; client thread. -// The downloader ends the session (queue_command "stop") when the player says no or the map +// The downloader ends its own attempt (queue_command "stop-map-fetch", with its deadline) +// when the player says no or the map // cannot be had. Should the session end meanwhile for another reason, // queue_command("rejoin", "", "") goes back to it, with the password it was joined with. struct MapNeed { @@ -42,15 +43,18 @@ 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. bool fetching_map() noexcept; -// Held while a map is being downloaded, installed or applied: the session does not load the -// host's map meanwhile, and waits as it does for one that is missing. A map that is being +std::uint64_t map_fetch_deadline() noexcept; +// Held for the named package while it is being downloaded, installed or applied: unrelated +// joins are not held. The session waits as it does for a missing map. A map that is being // replaced by another version is one the game already knows, so without this it would load as // soon as the new files are in place, before the mods are applied again. Thread-safe. -void hold_map_load(bool hold) noexcept; +void hold_map_load(bool hold, std::string_view package) noexcept; // How a host learns the package of the map it is on, to tell its guests: the level's asset in, // the package (or nothing) out. Called on the client thread, often; it should be quick. using MapPackageLookup = std::string (*)(std::string_view asset); diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index 14f887d..c16b45c 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -622,7 +622,7 @@ bool own_mark_command(std::string_view action) { } // namespace bool queue_command(std::string_view action, std::string_view argument, std::string_view password) { if (launcher::offline_mode() && !own_mark_command(action)) return false; - if ((action != "host" && action != "host-config" && action != "join" && action != "rejoin" && action != "join-lobby" && action != "join-friend-lobby" && action != "stop" && + if ((action != "host" && action != "host-config" && action != "join" && action != "rejoin" && action != "join-lobby" && action != "join-friend-lobby" && action != "stop" && action != "stop-map-fetch" && action != "distances" && action != "object-placement" && action != "object-limit" && action != "kick" && action != "clear-objects" && action != "nametags" && action != "chat-visible" && action != "chat-filter" && action != "nametag-distance" && action != "nametag-dots" && action != "nametags-friends" && action != "player-distance" && action != "direct-connections" && action != "pose-dump" && action != "vote" && action != "voice-chat" && @@ -875,7 +875,15 @@ std::string command(std::string_view action, std::string_view argument, std::str if (action == "status") return s.status + " | Players " + std::to_string(player_count(s)) + "/" + std::to_string(s.capacity); - if (action == "stop") { + if (action == "stop-map-fetch") { + std::uint64_t expected{}; + const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), expected); + // A worker's queued cancellation may execute after a fresh join command. + if (parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() || + !expected || expected != s.map_fetch_deadline) + return s.status; + } + if (action == "stop" || action == "stop-map-fetch") { stop(s, "Multiplayer stopped."); publish(s); return s.status; @@ -968,6 +976,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 +1123,8 @@ 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; + map_fetch_deadline_flag.store(s.map_fetch_deadline); 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 58a2e80..d22b61c 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -262,11 +262,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() @@ -475,14 +477,14 @@ std::string answer_server_poll(Session &s, std::size_t answer); inline std::atomic server_vote_open_flag{}; inline std::atomic map_package_lookup{}; inline std::atomic fetching_map_flag{}; -inline std::atomic map_load_held{}; +inline std::atomic map_fetch_deadline_flag{}, map_load_held{}; // A guest without the map is downloading it: the clock they are loading against starts over // each time they say so, for as long as map_fetch_limit_us from the first time. 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 75300e1..a9e4ea5 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -622,6 +622,16 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { continue; } const auto &p = *decoded; + if (s.mode == Mode::join && map_fetch_policy::expired(s.map_fetch_deadline, now)) { + stop(s, "Map download timed out. Please try joining the server again."); + return; + } + 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) { @@ -636,6 +646,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; @@ -646,6 +658,8 @@ 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; + map_fetch_deadline_flag.store(fetch_deadline); fetching_map_flag.store(false, std::memory_order_relaxed); s.map = 0; s.map_load_submitted = false; @@ -765,6 +779,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { s.map_package = p.map_package; s.world = p.world; s.join_map_authorized |= p.map_authorized; + link->last_packet = now; if (new_challenge) s.last_map_request = 0; if (!s.join_map_authorized) @@ -858,6 +873,10 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { link->world_ready = true; link->travel_since = 0; link->last_packet = now; + if (s.mode == Mode::join && message.peer == s.host_id && !s.awaiting_map) { + s.map_fetch_deadline = 0; + map_fetch_deadline_flag.store(0); + } if (joined) s.join_backoff.joined(message.peer); if (s.mode == Mode::host) { send_required(s, message.peer, encode_wire(packet(s, PacketKind::welcome, now))); @@ -1162,7 +1181,15 @@ 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; + const bool fetching_from_host = s.mode == Mode::join && p.member.id == s.host_id && + s.map_fetch_deadline && !map_fetch_policy::expired(s.map_fetch_deadline, now); if (p.handshaken && (s.travelling || p.travel_since)) { if (p.travel_since && now - p.travel_since > 180000000) { disconnect(s, p.member.id, "A player could not finish loading the new map."); @@ -1170,7 +1197,8 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) { } continue; } - if ((!p.handshaken && p.connected_at && now - p.connected_at > handshake_timeout) || + if ((!p.handshaken && p.connected_at && + (fetching_from_host ? now - p.last_packet > 10000000 : now - p.connected_at > handshake_timeout)) || (p.handshaken && now - p.last_packet > 10000000)) { disconnect(s, p.member.id, "A player timed out while waiting for gameplay data."); if (s.mode == Mode::off) 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..a263f1d --- /dev/null +++ b/Extension/Multiplayer/Test/map_fetch_policy_tests.cpp @@ -0,0 +1,89 @@ +#include "Extension/Multiplayer/Net/map_fetch_policy.h" +#include "Extension/Multiplayer/Net/protocol.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(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"); + 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/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index 229c92c..09b428a 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -1460,6 +1460,85 @@ void map_checks() { pair.run(1); check(pair.guest.mode == Mode::off, "Host departure left a pending map join active"); } + { + map_package_lookup.store(+[](std::string_view) { return std::string("Team-Map-1.0.0"); }); + MapPair pair; + MapPair::missing = true; + pair.run(20); + const auto deadline = pair.guest.map_fetch_deadline; + check(deadline && pair.guest.map_need && pair.guest.map_need->deadline_us == deadline, + "Downloader did not receive the session's authoritative deadline"); + pair.now += 180000001; + auto request = packet(pair.guest, PacketKind::map_request, pair.now); + request.map = pair.host.map; + request.map_fetching = true; + send_required(pair.guest, pair.host.host_id, encode_wire(request)); + networking(pair.host, pair.host_frame, pair.now); + networking(pair.guest, NativeFrame{}, pair.now); + check(pair.guest.mode == Mode::join && pair.guest.map_fetch_deadline == deadline, + "An active download was disconnected by the ordinary host-link handshake timeout"); + check(!prepare_join_map(pair.guest, false, pair.current, MapPair::loader, deadline - 1) && + pair.guest.mode == Mode::join, + "Map fetch expired before its deadline"); + check(!prepare_join_map(pair.guest, false, pair.current, MapPair::loader, deadline) && + pair.guest.mode == Mode::off, + "Map fetch survived its exact deadline"); + map_package_lookup.store(nullptr); + } + { + map_package_lookup.store(+[](std::string_view) { return std::string("Team-Map-1.0.0"); }); + MapPair pair; + MapPair::missing = true; + pair.run(20); + const auto deadline = pair.guest.map_fetch_deadline; + MapPair::missing = false; + pair.current = pair.host.map_name; + const auto now = deadline - 1; + check(prepare_join_map(pair.guest, true, pair.current, MapPair::loader, now), + "Installed map could not finish loading within the deadline"); + pair.guest.map = map_hash(pair.current); + pair.guest.map_name = pair.current; + find_peer(pair.guest, pair.host.host_id)->last_packet = now; // ongoing metadata heartbeats + check(pair.guest.map_fetch_deadline == deadline, + "Native loading completion prematurely discarded the join deadline"); + networking(pair.guest, pair.host_frame, now); + networking(pair.host, pair.host_frame, now); + networking(pair.guest, pair.host_frame, now); + check(find_peer(pair.guest, pair.host.host_id)->handshaken && !pair.guest.map_fetch_deadline, + "Welcome did not retire a successfully completed download deadline"); + check(prepare_join_map(pair.guest, true, pair.current, MapPair::loader, deadline + 1), + "A successfully joined player was disconnected by a retired deadline"); + map_package_lookup.store(nullptr); + } + { + MapPair pair; + pair.run(20); + hold_map_load(true, "Team-Unrelated-1.0.0"); + check(!prepare_join_map(pair.guest, false, pair.current, MapPair::loader, pair.now + 500000) && + pair.guest.mode == Mode::join && pair.guest.map_load_submitted, + "An abandoned download blocked an unrelated ordinary map load"); + hold_map_load(false, ""); + } + { + auto &waiting = session(); + stop(waiting, "Map cancellation fixture"); + waiting.mode = Mode::join; + waiting.map_fetch_deadline = 100; + check(queue_command("stop-map-fetch", "100", ""), "Map cancellation command was not queued"); + std::unique_ptr cancellation; + { + std::lock_guard lock(waiting.request_mutex); + cancellation = std::move(waiting.requests.back()); + waiting.requests.pop_back(); + } + // A fresh join executes before the old worker's cancellation is dispatched. + waiting.map_fetch_deadline = 200; + command(cancellation->action, cancellation->argument, cancellation->password); + check(waiting.mode == Mode::join && waiting.map_fetch_deadline == 200, + "A queued cancellation stopped a fresh map-fetch attempt"); + command("stop-map-fetch", "200"); + check(waiting.mode == Mode::off, "An owned cancellation did not stop its pending join"); + } std::cout << "Automatic map joining: codec, same/different maps, password-before-load, slow loading, " "skater readiness, missing maps, cancellation, timeout and host departure passed.\n"; } diff --git a/Launcher/Test/mod_manager_tests.cpp b/Launcher/Test/mod_manager_tests.cpp index e16c901..6070274 100644 --- a/Launcher/Test/mod_manager_tests.cpp +++ b/Launcher/Test/mod_manager_tests.cpp @@ -119,6 +119,28 @@ int main() { make_zip(sources / L"v2.zip", {{"Cool Park/layout.toc", "toc v2"}}); check(install(mods, sources / L"v2.zip", true) == "Cool Park" && read(mods / L"Cool Park" / L"layout.toc") == "toc v2", "Replacing installs the new copy"); + { + std::atomic cancel{}; + unsigned callbacks{}; + make_zip(sources / L"cancelled.zip", {{"Cool Park/layout.toc", "must not replace"}}); + const auto issue = error_of([&] { + launcher_mods::install(mods, sources / L"cancelled.zip", true, + [&](float) { if (++callbacks == 2) cancel.store(true); }, cancel); + }); + check(issue == "Install cancelled." && callbacks == 2, + "Cancellation at the final safe installation boundary was ignored"); + check(read(mods / L"Cool Park" / L"layout.toc") == "toc v2" && + !fs::exists(mods / L".reskate-install"), + "Cancelled replacement damaged the existing mod or left staging files"); + write(sources / L"cancelled folder" / L"Cool Park" / L"layout.toc", "must not replace"); + cancel.store(false); + callbacks = 0; + check(error_of([&] { + launcher_mods::install(mods, sources / L"cancelled folder", true, + [&](float) { if (++callbacks == 2) cancel.store(true); }, cancel); + }) == "Install cancelled." && read(mods / L"Cool Park" / L"layout.toc") == "toc v2", + "Cancelled folder replacement did not preserve the installed mod"); + } // Hostile or unrelated archives. make_zip(sources / L"evil.zip", {{"../evil.txt", "x"}, {"layout.toc", "toc"}}); 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 ca43364..70f1b48 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,21 @@ CachyOS: the launcher, the server browser, joining a public server and hosting a 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 ef5196e..d6f02fc 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_; } @@ -1096,6 +1096,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) { @@ -1793,6 +1796,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.");