Coordinate custom-map downloads with the multiplayer deadline

Co-authored-by: Jean-LucTch <88255067+Jean-LucTch@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]andJean-LucTch authored and GitHub committed 2026-10-13 11:11:27 +00:00
1 parent 28118efc27
commit 8eddcab142
17 files changed
+919 -70

No files matched your search

+9
View File
@@ -62,7 +62,16 @@ endif()
option(DINGOSDK_BUILD_MULTIPLAYER_TESTS "Build multiplayer protocol regression tests" OFF)
if(DINGOSDK_BUILD_MULTIPLAYER_TESTS)
enable_testing()
add_executable(dingosdk_map_fetch_policy_tests Extension/Multiplayer/Test/map_fetch_policy_tests.cpp)
target_include_directories(dingosdk_map_fetch_policy_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME map_fetch_policy COMMAND dingosdk_map_fetch_policy_tests)
if(WIN32)
add_executable(dingosdk_map_download_timeout_tests Extension/Assets/Test/map_download_timeout_tests.cpp
Engine/Vfs/thunderstore_package.cpp)
target_include_directories(dingosdk_map_download_timeout_tests PRIVATE "${PROJECT_SOURCE_DIR}")
target_link_libraries(dingosdk_map_download_timeout_tests PRIVATE dingosdk_json)
add_test(NAME map_download_timeout COMMAND dingosdk_map_download_timeout_tests
"${CMAKE_CURRENT_BINARY_DIR}/map-download-fixtures")
add_executable(dingosdk_voice_tests Extension/Multiplayer/Test/voice_chat_tests.cpp Extension/Multiplayer/Net/protocol.cpp
Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp)
target_link_libraries(dingosdk_voice_tests PRIVATE dingosdk_lz4 dingosdk_zstd)
+126 -17
View File
@@ -3,26 +3,107 @@
#include <winhttp.h>
#include <array>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <fstream>
#include <iterator>
#include <mutex>
#include <string>
#include <utility>
namespace dingosdk::https {
namespace {
std::uint64_t now_us() noexcept {
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
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<std::mutex> 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<AsyncRequest*>(context);
std::lock_guard<std::mutex> 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<WINHTTP_ASYNC_RESULT*>(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<std::mutex> lock(mutex);
expected = status;
completed = false;
error = read = 0;
}
template<class Stop>
DWORD wait(const Stop& stop) {
std::unique_lock<std::mutex> 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<char, 65536> 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<DWORD_PTR>(&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<char, 65536> 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<DWORD>(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<DWORD>(buffer.size()), nullptr)))
return fail(error);
const auto read = async.read;
DWORD written{};
if (!read) break;
if (total + read > max_bytes) return fail(ERROR_FILE_TOO_LARGE);
if (!WriteFile(output.value, buffer.data(), read, &written, nullptr) || written != read) return fail(ERROR_WRITE_FAULT);
@@ -75,27 +182,29 @@ Download get(std::wstring_view url, const std::filesystem::path& destination,
}
if (!total) return fail(ERROR_BAD_LENGTH);
if (!FlushFileBuffers(output.value)) return fail(GetLastError());
async.close();
if (const auto error = stop()) return fail(error);
output.complete = true;
result.ok = true;
return result;
}
std::optional<std::string> 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<unsigned> serial{};
wchar_t folder[MAX_PATH + 1]{};
const auto length = GetTempPathW(MAX_PATH, folder);
const auto path = std::filesystem::path(length ? std::wstring(folder, length) : L".") /
(L"reskate-get-" + std::to_wstring(GetCurrentProcessId()) + L"-" + std::to_wstring(++serial) + L".tmp");
std::error_code error;
std::filesystem::remove(path, error); // get needs a fresh destination
const auto download = get(url, path, max_bytes, timeout_seconds, agent);
const auto download = get(url, path, max_bytes, timeout_seconds, agent, watch);
if (result) *result = download;
std::optional<std::string> text;
if (download.ok) {
std::ifstream in(path, std::ios::binary);
text.emplace(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
}
std::filesystem::remove(path, error);
if (download.ok) std::filesystem::remove(path, error);
return text;
}
}
+3 -1
View File
@@ -16,6 +16,8 @@ struct Download {
struct Watch {
std::atomic<std::uint64_t> received{}, total{};
std::atomic<bool> cancel{};
// Absolute steady_clock epoch microseconds; zero leaves only the GET timeout.
std::atomic<std::uint64_t> 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<std::string> 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);
}
@@ -0,0 +1,455 @@
#include "Extension/Assets/map_download.h"
#include "Extension/Assets/live_mods.h"
#include "Extension/Multiplayer/Session/monotonic_clock.h"
#include "Extension/Multiplayer/Net/protocol.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Vfs/https_download.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Launcher/mod_manager.h"
#include "Extension/UI/Overlay/overlay.h"
#include <Windows.h>
#include <winhttp.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <mutex>
#include <stdexcept>
#include <thread>
namespace fixture {
namespace fs = std::filesystem;
namespace policy = dingosdk::multiplayer::map_fetch_policy;
fs::path root;
std::atomic<std::uint64_t> clock{1000000};
std::atomic<bool> downloading{}, installing{}, release_download{}, release_install{}, merging{};
std::atomic<bool> fetching{true}, held{}, observed_cancel{}, installed_cancel{};
std::atomic<unsigned> downloads{}, installs{}, applies{}, stops{}, rejoins{};
bool block_download{}, block_install{}, slow{}, transfer_error{}, fallback{};
dingosdk::MultiplayerModel session;
std::mutex messages_mutex, gate_mutex;
std::condition_variable gate;
std::vector<std::string> notices;
std::vector<std::uint64_t> http_budgets;
fs::path archive;
void require(bool condition, const char* message) {
if (!condition) throw std::runtime_error(message);
}
template<class Predicate>
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<DWORD>(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<std::string> 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<ModEntry>&) {}
}
namespace dingosdk::launcher_mods {
std::string install(const std::filesystem::path& root, const std::filesystem::path& source, bool,
const Progress& progress, const std::atomic<bool>& 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<std::string> applied_mods() { return {"Team-Map"}; }
std::string status() { return "applied"; }
}
namespace dingosdk::multiplayer {
MultiplayerModel model() { return fixture::session; }
bool fetching_map() noexcept { return fixture::fetching.load(); }
void hold_map_load(bool hold) noexcept { fixture::held.store(hold); }
bool queue_command(std::string_view action, std::string_view, std::string_view) {
if (action == "stop") ++fixture::stops;
if (action == "rejoin") ++fixture::rejoins;
return true;
}
std::string map_package_name(std::string_view folder, std::string_view version) {
return std::string(folder) + "-" + std::string(version);
}
}
namespace dingosdk::overlay {
void set_map_download_icon(const std::string&) noexcept {}
void notify(NoticeLevel, std::string title, std::string text) noexcept {
std::lock_guard lock(fixture::messages_mutex);
fixture::notices.push_back(title + ": " + text);
}
}
namespace dingosdk::logging {
bool enabled(Level) noexcept { return false; }
void write(Level, Context, Channel, std::string_view) noexcept {}
void write(Level, Channel, std::string_view) noexcept {}
}
namespace fixture {
namespace download = dingosdk::map_download;
using Stage = download::Stage;
void drain() {
release_download.store(true);
release_install.store(true);
gate.notify_all();
wait([] { return download::workers.load() == 0; }, "workers did not drain");
}
void reset() {
drain();
require(download::view().stage == Stage::idle, "previous lifecycle did not finish");
fs::remove_all(root / "Mods");
clock.store(1000000);
downloading.store(false);
installing.store(false);
release_download.store(false);
release_install.store(false);
merging.store(false);
fetching.store(true);
held.store(false);
observed_cancel.store(false);
installed_cancel.store(false);
downloads.store(0);
installs.store(0);
applies.store(0);
stops.store(0);
rejoins.store(0);
block_download = block_install = slow = transfer_error = fallback = false;
session = {};
session.active = true;
session.connected = false;
notices.clear();
http_budgets.clear();
archive.clear();
download::last_failed.clear();
}
void offer(std::uint64_t deadline = 0, bool update = false) {
dingosdk::multiplayer::MapNeed need{"Team-Map-1.0.0", "Test map", true, false,
update ? "0.9.0" : "", deadline};
download::offer(need);
wait([] { return download::asking(); }, "metadata did not reach confirmation");
wait([] { return download::workers.load() == 0; }, "metadata worker did not drain");
}
void start_apply() {
wait([] { return download::view().stage == Stage::applying; }, "did not install and reach applying");
drain();
download::tick();
require(applies.load() == 1 && held.load(), "apply starts once and holds session loading");
}
void applying() {
download::answer(true);
start_apply();
}
void joined() {
merging.store(false);
download::tick();
require(download::view().stage == Stage::joining, "completed merge advances to joining");
fetching.store(false);
session.connected = true;
clock.fetch_add(2500000);
download::tick();
require(download::view().stage == Stage::idle, "joined session clears lifecycle");
require(!held.load() && stops.load() == 0 && rejoins.load() == 0, "normal join does not leave or rejoin");
}
void idle(const char* reason) {
require(download::view().stage == Stage::idle, reason);
drain();
require(stops.load() == 1, "failure leaves waiting session exactly once");
download::tick();
require(held.load() == (merging.load() && download::apply_started.load()),
"failure holds map loading only while an uncancellable live merge drains");
require(archive.empty() || !fs::exists(archive), "partial archive removed");
}
void tests() {
reset();
offer();
applying();
require(downloads.load() == 1 && installs.load() == 1, "success downloads and installs exactly once");
joined();
reset();
block_download = true;
slow = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "slow download did not start");
clock.fetch_add(10 * 60 * policy::second_us);
download::watch.received.store(50);
download::tick();
require(download::view().stage == Stage::downloading && download::view().received == 50 && held.load(),
"progressing transfer remains active beyond old five minute deadline");
release_download.store(true);
gate.notify_all();
start_apply();
require(clock.load() > 5 * 60 * policy::second_us, "slow progressing transfer exceeds former five minute limit");
joined();
reset();
transfer_error = true;
offer();
download::answer(true);
wait([] { return download::view().stage == Stage::idle; }, "HTTP timeout did not fail");
idle("transfer timeout");
require(installs.load() == 0 && noticed("timed out"), "HTTP timeout cannot install");
transfer_error = false;
stops.store(0);
offer();
applying();
require(downloads.load() == 2, "timeout allows same package retry");
joined();
reset();
block_download = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "download did not start");
clock.store(download::deadline.load());
download::tick();
idle("active session deadline");
require(observed_cancel.load() && installs.load() == 0, "session expiry cancels active HTTP");
reset();
block_install = true;
offer();
download::answer(true);
wait([] { return installing.load(); }, "install did not start");
clock.store(download::deadline.load());
download::tick();
idle("install timeout");
require(installed_cancel.load() && applies.load() == 0 && noticed("could not be installed"), "installer observes cancellation");
reset();
offer();
applying();
clock.store(download::deadline.load());
download::tick();
idle("apply timeout");
require(merging.load() && rejoins.load() == 0, "late atomic merge must not join");
download::offer({"Team-Map-1.0.0", "Test map", true});
require(download::view().stage == Stage::idle && noticed("still finishing"), "busy live merge blocks retry");
merging.store(false);
download::tick();
require(rejoins.load() == 0 && !held.load(), "finished cancelled apply cannot rejoin and releases load hold");
reset();
offer();
download::answer(false);
idle("decline");
require(downloads.load() == 0 && noticed("cancelled"), "decline never downloads");
reset();
offer();
clock.store(download::ask_until.load() - 1);
download::tick();
require(download::asking(), "confirmation remains open one microsecond before deadline");
clock.fetch_add(1);
download::answer(true);
idle("confirmation exact boundary");
require(downloads.load() == 0 && noticed("two minutes"), "late click cannot bypass confirmation timeout");
reset();
offer();
clock.store(download::ask_until.load());
download::tick();
idle("confirmation tick timeout");
require(downloads.load() == 0 && noticed("two minutes"), "tick expires unanswered prompt");
reset();
offer(clock.load() + policy::completion_reserve_us);
download::answer(true);
idle("completion reserve boundary");
require(downloads.load() == 0, "no transfer starts when only completion reserve remains");
reset();
block_download = true;
offer(clock.load() + policy::completion_reserve_us + 1);
download::answer(true);
wait([] { return downloading.load(); }, "last microsecond transfer did not start");
require(download::transfer_until.load() == clock.load() + 1, "near-boundary transfer keeps reserve");
clock.fetch_add(1);
download::tick();
idle("last transfer microsecond");
require(observed_cancel.load() && installs.load() == 0, "transfer exact boundary cancels without install");
reset();
block_download = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "HTTP did not start for cancel race");
download::answer(false);
require(download::watch.cancel.load(), "cancel flag is immediate");
download::tick();
require(held.load(), "cancelled HTTP keeps map loading held until worker drains");
download::offer({"Team-Map-1.0.0", "Test map", true});
require(downloads.load() == 1 && noticed("still finishing"), "retry cannot reuse a draining HTTP worker");
drain();
require(observed_cancel.load() && !fs::exists(archive) && installs.load() == 0, "cancel cleans archive without install");
// The same package is retriable after a user cancellation.
block_download = false;
offer();
applying();
require(downloads.load() == 2, "retry fetches same package again");
// Retry guard deliberately queued another stop above.
stops.store(0);
joined();
reset();
block_download = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "HTTP did not start for completion race");
clock.store(download::transfer_until.load());
// Return a successful HTTP response before tick notices its expired deadline.
drain();
download::tick();
require(download::view().stage == Stage::idle && installs.load() == 0,
"success racing the transfer deadline cannot reach installation");
idle("transfer completion race");
reset();
fallback = true;
offer();
applying();
require(downloads.load() == 2 && http_budgets.size() == 2, "404 selects newest fallback");
require(http_budgets[0] == policy::transfer_us, "initial request gets at most 27 minutes");
require(http_budgets[1] == policy::transfer_us - 60 * policy::second_us, "fallback loses first request's minute");
joined();
reset();
fallback = true;
offer(0, true);
download::answer(true);
wait([] { return download::view().stage == Stage::idle; }, "missing host version did not fail");
idle("host version consistency");
require(downloads.load() == 1 && installs.load() == 0, "version mismatch never falls back to another host version");
reset();
offer();
const auto original_deadline = download::deadline.load();
download::offer({"Team-Other-1.0.0", "Other map", true});
require(download::asking() && download::deadline.load() == original_deadline && stops.load() == 1,
"host changing maps does not replace in-flight request or extend its deadline");
stops.store(0);
download::answer(false);
idle("host map change");
reset();
download::tick();
require(!held.load() && downloads.load() == 0 && stops.load() == 0 && rejoins.load() == 0,
"ordinary join without download is untouched");
}
}
int main(int argc, char** argv) {
try {
fixture::require(argc == 2, "pass a fixture directory");
fixture::root = std::filesystem::absolute(argv[1]);
std::filesystem::create_directories(fixture::root);
fixture::tests();
fixture::drain();
std::filesystem::remove_all(fixture::root);
std::cout << "Production map download timeout lifecycle tests passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
// A bounded wait failure can leave detached production workers alive. Do not
// destroy their fixture globals while they may still be using them.
std::_Exit(1);
}
}
+141 -40
View File
@@ -6,9 +6,11 @@
#include "Engine/Vfs/mod_list.h"
#include "Engine/Vfs/thunderstore_package.h"
#include "Extension/Multiplayer/Net/protocol.h"
#include "Extension/Multiplayer/Session/monotonic_clock.h"
#include "Extension/UI/Overlay/overlay.h"
#include "Launcher/mod_manager.h"
#include <Windows.h>
#include <winhttp.h>
#include <algorithm>
#include <atomic>
#include <fstream>
@@ -23,13 +25,14 @@ namespace package = thunderstore_package;
constexpr wchar_t agent[] = L"ReSkate";
constexpr char community[] = "reskate";
// A map is at most a few gigabytes (an archive of one holds four); the download may take as
// long as a slow line needs for that.
// Keep the existing package size restriction; time is bounded by the session policy.
constexpr std::uint64_t largest_download = 8ull << 30;
constexpr std::uint32_t download_seconds = 4 * 60 * 60;
namespace policy = multiplayer::map_fetch_policy;
std::mutex mutex;
std::atomic<Stage> stage{Stage::idle};
std::atomic<unsigned> workers{};
std::atomic<std::uint64_t> deadline{}, ask_until{}, transfer_until{};
multiplayer::MapNeed need; // under mutex, while not idle
package::Name wanted; // under mutex
std::string version; // under mutex: the one to download
@@ -70,22 +73,47 @@ void leave_session() {
}
// Back to idle with the reason said, as the plain "map not installed" notice is.
void fail(std::string reason) {
void fail(std::string reason, bool retry = false) {
std::string text;
bool update{};
{
std::lock_guard lock(mutex);
if (stage.exchange(Stage::idle) == Stage::idle) return;
watch.cancel.store(true);
stop_install.store(true);
apply_wanted.store(false);
update = !need.have.empty();
text = "The " + host_word(need.server) + (need.moved ? " changed to " : " is on ") + need.map +
(update ? ", at another version (" + std::string(multiplayer::map_package_version(need.package)) + ") than the one installed on this PC (" + need.have + "). "
: ", which is not installed on this PC. ") + reason;
last_failed = need.package;
if (!retry) last_failed = need.package;
}
stage.store(Stage::idle);
leave_session();
logging::log(logging::Level::warning, logging::Channel::assets, "Map download: {}", text);
overlay::notify(overlay::NoticeLevel::warning, update ? "Map version differs" : "Map not installed", std::move(text));
}
bool continuing() {
if (stage.load() == Stage::idle) return false;
if (policy::expired(deadline.load(), multiplayer::now_us())) {
const auto now = stage.load();
fail(now == Stage::installing || now == Stage::applying
? "The map could not be installed before the session timeout."
: "Map download timed out. Please try joining the server again.", true);
return false;
}
return !watch.cancel.load();
}
bool advance(Stage from, Stage to) {
if (!continuing()) return false;
return stage.compare_exchange_strong(from, to);
}
void ask() {
ask_until.store(std::min(deadline.load(), multiplayer::now_us() + policy::confirmation_us));
advance(Stage::checking, Stage::asking);
}
// The packages of the maps installed here, by level (package_of): forgotten when one is installed.
struct Known {
std::string package;
@@ -130,11 +158,12 @@ void check() {
const auto bytes = read_bytes(mods_root() / name.folder() / L"icon.png", 4 * 1024 * 1024);
overlay::set_map_download_icon(bytes);
} catch (const std::exception&) {}
stage.store(Stage::asking);
ask();
return;
}
https::Download result;
const auto answer = https::get_text(package::details_url(name), 512 * 1024, 20, agent, &result);
const auto answer = https::get_text(package::details_url(name), 512 * 1024, 20, agent, &result, &watch);
if (!continuing()) return;
if (!answer)
return fail(result.http_status == 404 ? "Its mod is not on Thunderstore. Install its map mod yourself and join again."
: "Thunderstore could not be reached to look for it.");
@@ -151,9 +180,9 @@ void check() {
}
// Its icon for the card: small, and not worth holding the question up for long.
if (!choice.icon.empty())
if (const auto png = https::get_text(std::wstring(choice.icon.begin(), choice.icon.end()), 2 * 1024 * 1024, 8, agent))
if (const auto png = https::get_text(std::wstring(choice.icon.begin(), choice.icon.end()), 2 * 1024 * 1024, 8, agent, nullptr, &watch))
overlay::set_map_download_icon(*png);
stage.store(Stage::asking);
ask();
} catch (const std::exception& failure) {
fail(std::string("Looking for it failed: ") + failure.what());
}
@@ -175,6 +204,7 @@ void fetch() {
const auto root = mods_root();
const auto folder = name.folder();
try {
if (!continuing()) return;
if (!off) {
wchar_t temp[MAX_PATH + 1]{};
const auto length = GetTempPathW(MAX_PATH, temp);
@@ -184,41 +214,57 @@ void fetch() {
fs::remove(archive, error);
watch.received.store(0);
watch.total.store(0);
auto result = https::get(package::download_url(name, get), archive, largest_download, download_seconds, agent, &watch);
// One transfer budget across fallback requests; leave time for installation and joining.
const auto transfer = transfer_until.load();
const auto download = [&](const std::string& selected) {
watch.deadline_us.store(transfer);
return https::get(package::download_url(name, selected), archive, largest_download,
static_cast<std::uint32_t>(policy::transfer_us / policy::second_us), agent, &watch);
};
auto result = download(get);
// The host's version can be gone from Thunderstore: the newest is the same map.
if (!update && !result.ok && result.http_status == 404 && get != fallback && !watch.cancel.load()) {
fs::remove(archive, error);
logging::log(logging::Level::info, logging::Channel::assets,
"Map download: {} {} is not on Thunderstore any more; getting {}.", folder, get, fallback);
result = https::get(package::download_url(name, fallback), archive, largest_download, download_seconds, agent, &watch);
result = download(fallback);
}
if (!result.ok) {
fs::remove(archive, error);
if (watch.cancel.load()) { stage.store(Stage::idle); leave_session(); return; }
if (!continuing()) return;
if (result.error == ERROR_TIMEOUT || result.error == ERROR_WINHTTP_TIMEOUT ||
policy::expired(transfer, multiplayer::now_us()))
return fail("Map download timed out. Please try joining the server again.", true);
return fail(result.http_status && result.http_status != 200
? "The download failed (Thunderstore answered " + std::to_string(result.http_status) + ")."
: "The download failed (Windows error " + std::to_string(result.error) + ").");
}
stage.store(Stage::installing);
if (!advance(Stage::downloading, Stage::installing)) {
fs::remove(archive, error);
return;
}
try {
// (In the installed one's place when it is the same map at another version. The
// game may hold the old one's files open when it was skated this run: then it
// cannot be replaced until the game is closed.)
launcher_mods::install(root, archive, update, [](float) {}, stop_install, {folder, name.owner, true});
launcher_mods::install(root, archive, update, [](float) { continuing(); }, stop_install, {folder, name.owner, true});
} catch (const std::exception& failure) {
fs::remove(archive, error);
if (!continuing()) return;
return fail(update ? std::string("It could not be replaced while the game runs (") + failure.what() +
"). Close the game, update it from the launcher and join again."
: std::string("It could not be installed: ") + failure.what());
}
fs::remove(archive, error);
if (!continuing()) return;
{
std::lock_guard known(known_mutex);
known_packages.clear();
}
} else {
stage.store(Stage::installing);
if (!advance(Stage::downloading, Stage::installing)) return;
}
if (!continuing()) return;
// A map and nothing else: this way in is for maps.
std::error_code error;
if (!fs::exists(root / folder / L"reskate-levels.json", error) || !live_mods::only_maps(root / folder)) {
@@ -228,16 +274,18 @@ void fetch() {
return fail("Its package holds more than a map, so it is not installed from here. Install it from the launcher if you trust it.");
}
if (off || update) { // (a map at another version may have been switched off: it is wanted now)
if (!continuing()) return;
auto list = mods::scan_mods(mods::engine_data_root());
if (!list.issue.empty()) return fail("mods.json could not be read: " + list.issue);
for (auto& entry : list.entries)
if (same(entry.mod.name, folder)) entry.enabled = true;
mods::save_mod_order(list.root, list.entries);
}
if (!continuing()) return;
logging::log(logging::Level::info, logging::Channel::assets, "Map download: {} is in Mods; applying it.", folder);
apply_started.store(false);
apply_wanted.store(true);
stage.store(Stage::applying);
if (!advance(Stage::installing, Stage::applying)) apply_wanted.store(false);
} catch (const std::exception& failure) {
fail(std::string("It could not be installed: ") + failure.what());
}
@@ -245,10 +293,29 @@ void fetch() {
// One piece of work at a time, which the stage sees to: each is started by the move into its
// stage. Detached, as the live merge's thread is: the game may close while a map downloads.
void start(void (*work)()) { std::thread(work).detach(); }
void start(void (*work)()) {
++workers;
try {
std::thread([work] {
struct Finished { ~Finished() { --workers; } } finished;
work();
}).detach();
} catch (...) {
--workers;
throw;
}
}
} // namespace
void offer(const multiplayer::MapNeed& asked) {
std::lock_guard lock(mutex);
// A cancelled worker/atomic live merge must drain before its globals or files are reused.
if (stage.load() == Stage::idle && (workers.load() || (apply_started.load() && live_mods::busy()))) {
leave_session();
overlay::notify(overlay::NoticeLevel::warning, "Map download",
"The previous map operation is still finishing. Please try joining again shortly.");
return;
}
auto idle = Stage::idle;
const auto name = package::parse_name(asked.package);
// (Taken before anything is set up, so a second need while this one is in hand is dropped.)
@@ -256,10 +323,7 @@ void offer(const multiplayer::MapNeed& asked) {
// Another map is in hand (the session moved on while it came). One at a time: the one
// being fetched is finished, and the session, which now waits on this other one, is left.
bool other{};
{
std::lock_guard lock(mutex);
other = asked.package != need.package;
}
other = asked.package != need.package;
if (other) {
overlay::notify(overlay::NoticeLevel::warning, "Map changed again",
"The " + host_word(asked.server) + " moved on to " + asked.map + " while another map was downloading. Join again when it is done.");
@@ -268,7 +332,6 @@ void offer(const multiplayer::MapNeed& asked) {
return;
}
{
std::lock_guard lock(mutex);
need = asked;
if (!name || asked.package == last_failed) {
// (Said once already, or nothing this can fetch: the plain notice.)
@@ -287,7 +350,14 @@ void offer(const multiplayer::MapNeed& asked) {
}
overlay::set_map_download_icon({});
watch.cancel.store(false);
const auto now = multiplayer::now_us();
deadline.store(asked.deadline_us ? asked.deadline_us : now + policy::session_us);
ask_until.store(0);
transfer_until.store(0);
watch.deadline_us.store(deadline.load());
stop_install.store(false);
apply_wanted.store(false);
apply_started.store(false);
pick_download.store(true);
start(check);
}
@@ -295,15 +365,32 @@ void offer(const multiplayer::MapNeed& asked) {
void answer(bool download) noexcept {
try {
// (A click and a key can answer in the same moment: only the first does.)
auto asked = Stage::asking;
if (stage.compare_exchange_strong(asked, download ? Stage::downloading : Stage::idle)) {
if (download) start(fetch);
else leave_session();
} else if (asked == Stage::downloading && !download) {
watch.cancel.store(true);
if (!continuing()) return;
const auto now = multiplayer::now_us();
if (stage.load() == Stage::asking && policy::expired(ask_until.load(), now)) {
fail("The map download was not confirmed within two minutes. Please try joining again.", true);
return;
}
if (!download) {
if (stage.load() == Stage::asking || stage.load() == Stage::downloading)
fail("Map download cancelled.", true);
} else {
bool no_time{};
{
// Publish the stage and claim its worker together. Cancellation cannot allow
// a fresh offer to reuse the globals before this thread starts its worker.
std::lock_guard lock(mutex);
auto asked = Stage::asking;
if (!watch.cancel.load() && stage.compare_exchange_strong(asked, Stage::downloading)) {
transfer_until.store(policy::transfer_deadline(deadline.load(), now));
no_time = policy::expired(transfer_until.load(), now) && !installed_off;
if (!no_time) start(fetch);
}
}
if (no_time) fail("Map download timed out. Please try joining the server again.", true);
}
} catch (...) {
stage.store(Stage::idle);
fail("The map download could not be started. Please try joining again.", true);
}
}
@@ -345,10 +432,23 @@ View view() {
}
void tick() {
const auto now = stage.load();
auto now = stage.load();
if (now != Stage::idle) {
const auto session = multiplayer::model();
if (!session.active && session.status.find("stopped waiting for the map download") != std::string::npos)
fail("The server stopped waiting for the map download.", true);
if (continuing() && now == Stage::asking &&
policy::expired(ask_until.load(), multiplayer::now_us()))
fail("The map download was not confirmed within two minutes. Please try joining again.", true);
if (continuing() && now == Stage::downloading &&
policy::expired(transfer_until.load(), multiplayer::now_us()))
fail("Map download timed out. Please try joining the server again.", true);
now = stage.load();
}
// The session leaves the map alone until it has been applied (an update's level is one the
// game already has, and would load from files that are being replaced).
multiplayer::hold_map_load(now == Stage::downloading || now == Stage::installing || now == Stage::applying);
multiplayer::hold_map_load((now != Stage::idle && now != Stage::joining) ||
workers.load() || (apply_started.load() && live_mods::busy()));
if (now == Stage::applying) {
if (apply_wanted.load()) {
// (A merge of the player's own may be running: wait for it and start this one.)
@@ -371,24 +471,25 @@ void tick() {
return fail("It is installed, but was left out when the mods were applied (" + live_mods::status() + ").");
// The level list takes the new map on the next ticks, and the session, which has been
// trying the map all along, loads it then.
join_at.store(GetTickCount64());
stage.store(Stage::joining);
join_at.store(multiplayer::now_us());
advance(Stage::applying, Stage::joining);
return;
}
if (now == Stage::joining) {
const auto waited = GetTickCount64() - join_at.load();
if (waited < 2500) return;
const auto waited = multiplayer::now_us() - join_at.load();
if (waited < 2500000) return;
// Still waiting on it: the game has the mod and cannot find the map in it.
if (multiplayer::fetching_map()) {
if (waited > 30000) fail("It is installed, but the game still cannot load the map. Restart the game and join again.");
const auto session = multiplayer::model();
if (multiplayer::fetching_map() || (session.active && !session.connected)) {
if (waited >= policy::installed_load_us) fail("It is installed, but the game still cannot load the map. Restart the game and join again.");
return;
}
{
// Should it not load after all, it is said and not offered round and round.
std::lock_guard lock(mutex);
last_failed = need.package;
last_failed.clear();
}
stage.store(Stage::idle);
if (!continuing() || !stage.compare_exchange_strong(now, Stage::idle)) return;
// The session ended while the map came (the host left, the connection dropped): back to it.
if (!multiplayer::model().active && !multiplayer::queue_command("rejoin", "", ""))
overlay::notify(overlay::NoticeLevel::warning, "Map installed", "Join the session again from the server browser.");
+3
View File
@@ -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
@@ -0,0 +1,27 @@
#pragma once
#include <algorithm>
#include <cstdint>
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;
}
}
+4 -3
View File
@@ -1,4 +1,5 @@
#pragma once
#include "map_fetch_policy.h"
#include "Extension/Multiplayer/Remote/cosmetics.h"
#include "Extension/Multiplayer/Session/peer_slots.h"
#include "Extension/Multiplayer/Session/object_state.h"
@@ -207,9 +208,9 @@ bool valid_map_asset(std::string_view asset) noexcept;
bool valid_map_pool(std::span<const std::uint16_t> 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.
+9 -7
View File
@@ -155,6 +155,7 @@ void stop(Session &s, std::string reason) {
s.map_label.clear();
s.map_package.clear();
s.fetching_since = 0;
s.map_fetch_deadline = 0;
fetching_map_flag.store(false, std::memory_order_relaxed);
s.world = 1;
s.travelling = false;
@@ -768,6 +769,7 @@ bool observe_local_world(Session &s, const NativeFrame &local, std::string_view
} else if (s.travelling) {
if (local.ready && !s.awaiting_map && s.host_world_ready) {
s.travelling = false;
s.map_fetch_deadline = 0;
s.last_world_ready = 0;
for (auto &peer : active_peers(s)) {
peer.last_packet = now;
@@ -796,6 +798,10 @@ bool observe_local_world(Session &s, const NativeFrame &local, std::string_view
// Native loading can unload every local actor. Keep the connection in preflight
// until the selected map and a controllable skater are available.
bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoader loader, std::uint64_t now) {
if (map_fetch_policy::expired(s.map_fetch_deadline, now)) {
stop(s, "Map download timed out. Please try joining the server again.");
return false;
}
if (!s.awaiting_map)
return true;
// (While the map is being fetched the clock is held: see below.)
@@ -844,16 +850,12 @@ bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoade
if (!s.map_package.empty()) {
if (!s.fetching_since) {
s.fetching_since = now;
if (!s.map_fetch_deadline) s.map_fetch_deadline = now + map_fetch_limit_us;
fetching_map_flag.store(true, std::memory_order_relaxed);
s.map_need = MapNeed{s.map_package, name, dedicated_host(s), s.travelling, have};
s.rejoin = Session::Rejoin{{s.host_id, s.secret}, s.password};
s.map_need = MapNeed{s.map_package, name, dedicated_host(s), s.travelling, have, s.map_fetch_deadline};
s.rejoin = Session::Rejoin{{s.host_id, s.secret}, s.password, s.map_fetch_deadline};
s.last_map_request = s.last_world_ready = 0; // say so at once
}
if (now - s.fetching_since > map_fetch_limit_us) {
s.leave_notice = where + ", which did not finish downloading in time. Install its map mod and join again.";
stop(s, s.leave_notice);
return false;
}
// The time a load gets starts when the map is here.
s.join_started = now;
if (s.travelling) s.travel_started = now;
+2
View File
@@ -39,6 +39,8 @@ struct MapNeed {
// The version of it installed here, when that is another than the host's: the host's is
// fetched in its place. Empty: the map is not installed.
std::string have;
// Local steady-clock deadline, shared with the downloader, never transmitted.
std::uint64_t deadline_us{};
};
std::optional<MapNeed> take_map_need();
// Whether a session is waiting on a map being fetched right now. Thread-safe.
@@ -968,6 +968,8 @@ std::string command(std::string_view action, std::string_view argument, std::str
if (action == "rejoin") {
rejoin = std::exchange(s.rejoin, std::nullopt);
if (!rejoin) return "There is no session to go back to.";
if (map_fetch_policy::expired(rejoin->deadline_us, now_us()))
return "Map download timed out. Please try joining the server again.";
}
if (action != "host" && action != "join" && action != "echo" && !rejoin)
return "Unknown multiplayer action.";
@@ -1113,6 +1115,7 @@ std::string command(std::string_view action, std::string_view argument, std::str
s.host_id = invitation->steam_id;
s.awaiting_map = true;
s.join_started = now_us();
s.map_fetch_deadline = rejoin ? rejoin->deadline_us : 0;
s.status = "Connecting and checking the host's map...";
}
set_lobby_park_mode(s.mode == Mode::host || s.mode == Mode::join, s.mode == Mode::join);
@@ -261,11 +261,13 @@ struct Session {
// The map is not installed and is being fetched (take_map_need): since when, 0 for not.
// The host is told with what is sent while waiting (Packet::map_fetching).
std::uint64_t fetching_since{};
std::uint64_t map_fetch_deadline{};
std::optional<MapNeed> 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<PasswordKey> password;
std::uint64_t deadline_us{};
};
std::optional<Rejoin> rejoin;
std::string leave_notice; // take_leave_notice()
@@ -480,7 +482,7 @@ inline void guest_fetching(Peer &peer, std::uint64_t now) {
// (Only for a guest who is past the password.)
if (!peer.handshaken && !peer.map_authorized) return;
if (!peer.fetching_since) peer.fetching_since = now;
if (now - peer.fetching_since > map_fetch_limit_us) return;
if (map_fetch_policy::host_expired(peer.fetching_since, now)) return;
if (!peer.handshaken) peer.connected_at = now;
else if (peer.travel_since) peer.travel_since = now;
}
@@ -594,6 +594,12 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
continue;
}
const auto &p = *decoded;
if (s.mode == Mode::host && link->fetching_since && (!link->handshaken || link->travel_since) &&
map_fetch_policy::host_expired(link->fetching_since, now)) {
disconnect(s, message.peer, "The server stopped waiting for the map download.");
if (s.mode == Mode::off) return;
continue;
}
// A dedicated server sends no poses; its roster and control traffic keep it alive.
if (dedicated_host(s) && message.peer == s.host_id) link->last_packet = now;
if (p.kind == PacketKind::world_state) {
@@ -608,6 +614,8 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
!newer_sequence(p.sequence, s.world_state_sequence)))
continue;
if (p.world > s.world) {
// Initial authorization during an automatic rejoin keeps the original budget.
const auto fetch_deadline = s.world ? 0 : s.map_fetch_deadline;
clear_world(s, now);
s.world = p.world;
s.travelling = s.awaiting_map = s.join_map_authorized = true;
@@ -618,6 +626,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
s.map_label.clear();
s.map_package.clear();
s.fetching_since = 0;
s.map_fetch_deadline = fetch_deadline;
fetching_map_flag.store(false, std::memory_order_relaxed);
s.map = 0;
s.map_load_submitted = false;
@@ -637,6 +646,8 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
s.host_world_ready = p.world_ready;
link->world_ready = p.world_ready;
link->last_packet = now;
if (s.mode == Mode::join && message.peer == s.host_id && !s.awaiting_map)
s.map_fetch_deadline = 0;
if (s.travelling)
s.status = p.world_ready ? "Host map ready. Waiting for local loading..."
: "The host is changing maps. Your session stays connected.";
@@ -1134,6 +1145,12 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
}
// Authorized arrivals get time for native loading; this has an absolute
// deadline, so requests cannot keep an unused slot reserved indefinitely.
if (s.mode == Mode::host && p.fetching_since && (!p.handshaken || p.travel_since) &&
map_fetch_policy::host_expired(p.fetching_since, now)) {
disconnect(s, p.member.id, "The server stopped waiting for the map download.");
if (s.mode == Mode::off) return;
continue;
}
const std::uint64_t handshake_timeout = p.map_authorized ? 180000000 : 8000000;
if (p.handshaken && (s.travelling || p.travel_since)) {
if (p.travel_since && now - p.travel_since > 180000000) {
@@ -0,0 +1,86 @@
#include "Extension/Multiplayer/Net/map_fetch_policy.h"
#include <array>
#include <cstdint>
#include <iostream>
#include <limits>
#include <stdexcept>
namespace policy = dingosdk::multiplayer::map_fetch_policy;
void require(bool condition, const char* message) {
if (!condition) throw std::runtime_error(message);
}
int main() {
try {
require(policy::second_us == 1000000, "microsecond units");
require(policy::session_us == 1800 * policy::second_us, "30 minute session");
require(policy::transfer_us == 1620 * policy::second_us, "27 minute transfer");
require(policy::completion_reserve_us == 180 * policy::second_us, "3 minute completion reserve");
require(policy::confirmation_us == 120 * policy::second_us, "2 minute confirmation");
require(policy::installed_load_us == 30 * policy::second_us, "30 second installed-map load");
require(policy::transfer_us + policy::completion_reserve_us == policy::session_us, "budgets partition session");
require(!policy::expired(0, 0) && !policy::expired(0, std::numeric_limits<std::uint64_t>::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<std::uint64_t, 3>{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;
}
}
+7
View File
@@ -291,6 +291,7 @@ std::string install_zip(const fs::path& mods, const fs::path& source, bool repla
std::vector<std::string> 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<std::string> files;
std::vector<fs::path> 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<float>(written) / static_cast<float>(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;
}
+15
View File
@@ -77,6 +77,21 @@ a public server and hosting a lobby. Steam Deck should work the same way but is
custom map and then join a server on another map, the screen can stay black
([#31](https://github.com/Dingo-Shenanigans/ReSkate/issues/31)): load the server's map first.
### Multiplayer map downloads
Missing multiplayer custom maps can be downloaded from Thunderstore after confirmation.
The confirmation expires after two minutes. Checking, downloading, installing, applying and
joining share one 30-minute budget; HTTP transfers get at most 27 minutes and leave three minutes
for completion (confirmation and metadata checks reduce the available transfer time).
Dedicated servers and player-hosted lobbies enforce the same overall limit on their own
monotonic clocks. Ordinary map loading still gets 180 seconds; loading after a live installation
still gets 30 seconds, capped by the remaining budget.
Timeouts cancel HTTP transfers and remove incomplete temporary downloads. Installation stops
before replacing an existing mod when cancellation is observed. A filesystem commit or live
merge already in progress finishes safely rather than being forcibly interrupted; it cannot
restart a timed-out join. Retry after that work has finished, without restarting ReSkate.
### Controls
| Key | Opens |
+9 -1
View File
@@ -211,7 +211,7 @@ void Host::fetching(Guest &guest) {
guest.fetching_since = now_;
log_("[map] " + guest_name(guest) + " is downloading the map");
}
if (now_ - guest.fetching_since > map_fetch_limit_us) return;
if (map_fetch_policy::host_expired(guest.fetching_since, now_)) return;
if (!guest.handshaken) guest.connected_at = now_;
else if (guest.travel_since) guest.travel_since = now_;
}
@@ -1093,6 +1093,9 @@ void Host::receive(std::uint64_t peer, std::span<const std::uint8_t> bytes, std:
if (!decoded || decoded->session != secret_ || decoded->source != peer)
return drop(peer, "Join code, sender identity, or multiplayer protocol did not match.");
const auto &p = *decoded;
if (link->fetching_since && (!link->handshaken || link->travel_since) &&
map_fetch_policy::host_expired(link->fetching_since, now_))
return drop(peer, "The server stopped waiting for the map download.");
link->traffic.total.in[traffic_kind(p.kind)] += bytes.size();
traffic_.total.in[traffic_kind(p.kind)] += bytes.size();
switch (p.kind) {
@@ -1790,6 +1793,11 @@ void Host::tick(std::uint64_t now) {
auto &g = *(it++)->second;
// Authorized arrivals get time for loading; this deadline is absolute, so
// requests cannot hold an unused slot indefinitely.
if (g.fetching_since && (!g.handshaken || g.travel_since) &&
map_fetch_policy::host_expired(g.fetching_since, now_)) {
drop(g.member.id, "The server stopped waiting for the map download.");
continue;
}
const std::uint64_t handshake_timeout = g.map_authorized ? 180000000 : 8000000;
if (g.handshaken && g.travel_since) {
if (now_ - g.travel_since > 180000000) drop(g.member.id, "Could not finish loading the new map.");