mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 12:57:01 +02:00
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:
1 parent
28118efc27
commit
8eddcab142
17 files changed
+919
-70
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.");
|
||||
|
||||
Reference in new issue
Block a user