Merge pull request #225 from Jean-LucTch/dev

Fix multiplayer custom map download and join timeout handling
This commit is contained in:
zeex64 authored and GitHub committed 2026-10-15 14:03:06 -05:00
commit e7c6df0df6
20 files changed
+1640 -84

No files matched your search

+14
View File
@@ -62,7 +62,21 @@ endif()
option(DINGOSDK_BUILD_MULTIPLAYER_TESTS "Build multiplayer protocol regression tests" OFF)
if(DINGOSDK_BUILD_MULTIPLAYER_TESTS)
enable_testing()
add_executable(dingosdk_map_fetch_policy_tests Extension/Multiplayer/Test/map_fetch_policy_tests.cpp)
target_include_directories(dingosdk_map_fetch_policy_tests PRIVATE "${PROJECT_SOURCE_DIR}")
add_test(NAME map_fetch_policy COMMAND dingosdk_map_fetch_policy_tests)
if(WIN32)
add_executable(dingosdk_https_download_timeout_tests Engine/Vfs/Test/https_download_timeout_tests.cpp)
target_include_directories(dingosdk_https_download_timeout_tests PRIVATE "${PROJECT_SOURCE_DIR}")
target_link_libraries(dingosdk_https_download_timeout_tests PRIVATE winhttp)
add_test(NAME https_download_timeout COMMAND dingosdk_https_download_timeout_tests
"${CMAKE_CURRENT_BINARY_DIR}/https-download-fixtures")
add_executable(dingosdk_map_download_timeout_tests Extension/Assets/Test/map_download_timeout_tests.cpp
Engine/Vfs/thunderstore_package.cpp)
target_include_directories(dingosdk_map_download_timeout_tests PRIVATE "${PROJECT_SOURCE_DIR}")
target_link_libraries(dingosdk_map_download_timeout_tests PRIVATE dingosdk_json)
add_test(NAME map_download_timeout COMMAND dingosdk_map_download_timeout_tests
"${CMAKE_CURRENT_BINARY_DIR}/map-download-fixtures")
add_executable(dingosdk_voice_tests Extension/Multiplayer/Test/voice_chat_tests.cpp Extension/Multiplayer/Net/protocol.cpp
Extension/Multiplayer/Net/delta_codec.cpp Extension/Multiplayer/Net/wire_codec.cpp)
target_link_libraries(dingosdk_voice_tests PRIVATE dingosdk_lz4 dingosdk_zstd)
@@ -0,0 +1,334 @@
#include <Windows.h>
#include <winhttp.h>
#include "../https_download.h"
#include <array>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <mutex>
#include <string>
#include <utility>
namespace fake_http {
enum class Stage { none, send, receive, read, eof, close };
struct Handle {
DWORD_PTR context{};
WINHTTP_STATUS_CALLBACK callback{};
DWORD notifications{};
bool closed{};
};
struct State {
dingosdk::https::Watch* watch{};
Stage stop_at{Stage::none}, fail_at{Stage::none};
bool timeout{}, callback_error{}, content_length{true}, downgrade{};
unsigned opens{}, reads{}, closing_callbacks{}, pending_buffer_closes{};
DWORD redirect{}, request_flags{}, session_flags{};
void* pending_buffer{};
Handle session, connection, request;
std::string body{"payload"};
};
State state;
void stop() {
if (state.timeout) state.watch->deadline_us.store(1);
else state.watch->cancel.store(true);
}
void notify(DWORD status, void* info = nullptr, DWORD size = 0) {
state.request.callback(&state.request, state.request.context, status, info, size);
}
bool pending(Stage stage) {
if (state.stop_at != stage) return false;
stop();
return true;
}
bool failure(Stage stage) {
if (state.fail_at != stage) return false;
if (state.callback_error) {
WINHTTP_ASYNC_RESULT error{};
error.dwError = ERROR_WINHTTP_CONNECTION_ERROR;
notify(WINHTTP_CALLBACK_STATUS_REQUEST_ERROR, &error, sizeof(error));
} else {
SetLastError(ERROR_WINHTTP_CONNECTION_ERROR);
}
return true;
}
HINTERNET WINAPI open(LPCWSTR, DWORD, LPCWSTR, LPCWSTR, DWORD flags) {
++state.opens;
state.session_flags = flags;
return &state.session;
}
HINTERNET WINAPI connect(HINTERNET, LPCWSTR, INTERNET_PORT, DWORD) {
return &state.connection;
}
HINTERNET WINAPI open_request(HINTERNET, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR*, DWORD flags) {
state.request_flags = flags;
return &state.request;
}
BOOL WINAPI set_timeouts(HINTERNET, int, int, int, int) { return TRUE; }
BOOL WINAPI set_option(HINTERNET handle, DWORD option, LPVOID value, DWORD) {
if (option == WINHTTP_OPTION_CONTEXT_VALUE)
static_cast<Handle*>(handle)->context = *static_cast<DWORD_PTR*>(value);
if (option == WINHTTP_OPTION_REDIRECT_POLICY) state.redirect = *static_cast<DWORD*>(value);
return TRUE;
}
WINHTTP_STATUS_CALLBACK WINAPI set_callback(HINTERNET handle, WINHTTP_STATUS_CALLBACK callback,
DWORD notifications, DWORD_PTR) {
auto& request = *static_cast<Handle*>(handle);
const auto previous = request.callback;
request.callback = callback;
request.notifications = notifications;
return previous;
}
BOOL WINAPI send(HINTERNET, LPCWSTR, DWORD, LPVOID, DWORD, DWORD, DWORD_PTR context) {
state.request.context = context;
if (pending(Stage::send)) return TRUE;
if (failure(Stage::send)) return state.callback_error ? TRUE : FALSE;
notify(WINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE);
return TRUE;
}
BOOL WINAPI receive(HINTERNET, LPVOID) {
if (pending(Stage::receive)) return TRUE;
if (failure(Stage::receive)) return state.callback_error ? TRUE : FALSE;
if (state.downgrade && state.redirect == WINHTTP_OPTION_REDIRECT_POLICY_DISALLOW_HTTPS_TO_HTTP) {
SetLastError(ERROR_WINHTTP_REDIRECT_FAILED);
return FALSE;
}
notify(WINHTTP_CALLBACK_STATUS_HEADERS_AVAILABLE);
return TRUE;
}
BOOL WINAPI query(HINTERNET, DWORD query, LPCWSTR, LPVOID value, LPDWORD size, LPDWORD) {
if (query == (WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER)) {
*static_cast<DWORD*>(value) = 200;
} else if (query == (WINHTTP_QUERY_CONTENT_LENGTH | WINHTTP_QUERY_FLAG_NUMBER) && state.content_length) {
*static_cast<DWORD*>(value) = static_cast<DWORD>(state.body.size());
} else {
SetLastError(ERROR_WINHTTP_HEADER_NOT_FOUND);
return FALSE;
}
*size = sizeof(DWORD);
return TRUE;
}
BOOL WINAPI read(HINTERNET, LPVOID buffer, DWORD capacity, LPDWORD count) {
// The production async path must receive the count through its callback.
if (count || capacity < state.body.size()) {
SetLastError(ERROR_INVALID_PARAMETER);
return FALSE;
}
if (++state.reads == 1) {
std::memcpy(buffer, state.body.data(), state.body.size());
notify(WINHTTP_CALLBACK_STATUS_READ_COMPLETE, buffer, static_cast<DWORD>(state.body.size()));
} else {
if (state.stop_at == Stage::read) {
state.pending_buffer = buffer;
stop();
return TRUE;
}
if (failure(Stage::read)) return state.callback_error ? TRUE : FALSE;
if (state.stop_at == Stage::eof) stop();
notify(WINHTTP_CALLBACK_STATUS_READ_COMPLETE, buffer, 0);
}
return TRUE;
}
BOOL WINAPI close(HINTERNET handle) {
auto& object = *static_cast<Handle*>(handle);
if (object.closed) {
SetLastError(ERROR_INVALID_HANDLE);
return FALSE;
}
object.closed = true;
if (handle == &state.request && object.callback) {
if (state.pending_buffer) {
// Pending I/O can still touch the caller's buffer during closure.
*static_cast<char*>(state.pending_buffer) = 'x';
++state.pending_buffer_closes;
state.pending_buffer = nullptr;
}
if (state.stop_at == Stage::close) stop();
if (object.context && (object.notifications & WINHTTP_CALLBACK_FLAG_HANDLES)) {
++state.closing_callbacks;
notify(WINHTTP_CALLBACK_STATUS_HANDLE_CLOSING);
}
}
return TRUE;
}
}
#define WinHttpOpen fake_http::open
#define WinHttpConnect fake_http::connect
#define WinHttpOpenRequest fake_http::open_request
#define WinHttpSetTimeouts fake_http::set_timeouts
#define WinHttpSetOption fake_http::set_option
#define WinHttpSetStatusCallback fake_http::set_callback
#define WinHttpSendRequest fake_http::send
#define WinHttpReceiveResponse fake_http::receive
#define WinHttpQueryHeaders fake_http::query
#define WinHttpReadData fake_http::read
#define WinHttpCloseHandle fake_http::close
#include "../https_download.cpp"
#undef WinHttpOpen
#undef WinHttpConnect
#undef WinHttpOpenRequest
#undef WinHttpSetTimeouts
#undef WinHttpSetOption
#undef WinHttpSetStatusCallback
#undef WinHttpSendRequest
#undef WinHttpReceiveResponse
#undef WinHttpQueryHeaders
#undef WinHttpReadData
#undef WinHttpCloseHandle
namespace {
int failures{};
void check(bool condition, const char* what) {
if (condition) return;
std::cerr << "FAIL: " << what << '\n';
++failures;
}
std::string contents(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
}
struct Fixtures {
std::filesystem::path root;
bool created;
explicit Fixtures(std::filesystem::path path) : root(std::move(path)),
created(std::filesystem::create_directory(root)) {}
~Fixtures() {
if (created) {
std::error_code error;
std::filesystem::remove_all(root, error);
}
}
};
void reset(dingosdk::https::Watch& watch) {
fake_http::state = {};
fake_http::state.watch = &watch;
watch.cancel.store(false);
watch.deadline_us.store(0);
watch.received.store(0);
watch.total.store(0);
}
}
int main(int argc, char** argv) {
using namespace dingosdk::https;
using fake_http::Stage;
Fixtures fixtures(argc > 1 ? std::filesystem::path(argv[1]) : std::filesystem::current_path() /
("https-download-timeout-fixtures-" + std::to_string(GetCurrentProcessId())));
if (!fixtures.created) {
std::cerr << "Fixture directory already exists; refusing to overwrite it\n";
return 1;
}
const auto path = fixtures.root / "download.bin";
Watch watch;
const auto get = [&](std::uint64_t limit = 1024, std::uint32_t seconds = 5) {
return dingosdk::https::get(L"https://example.invalid/file", path, limit, seconds, L"test", &watch);
};
const auto removed = [&] { return !std::filesystem::exists(path); };
const auto closed = [&] {
return fake_http::state.closing_callbacks == 1 && fake_http::state.request.closed &&
fake_http::state.connection.closed && fake_http::state.session.closed;
};
reset(watch);
auto result = get();
check(result.ok && result.http_status == 200 && result.error == 0, "actual GET succeeds");
check(contents(path) == "payload" && watch.received.load() == 7 && watch.total.load() == 7,
"successful bytes and progress are retained");
check(closed(), "successful request receives final closing callback");
check(fake_http::state.session_flags & WINHTTP_FLAG_ASYNC, "session uses asynchronous WinHTTP");
check(fake_http::state.request_flags & WINHTTP_FLAG_SECURE, "request stays HTTPS");
check(fake_http::state.redirect == WINHTTP_OPTION_REDIRECT_POLICY_DISALLOW_HTTPS_TO_HTTP,
"HTTPS downgrade redirect policy is configured");
std::filesystem::remove(path);
for (const auto stage : {Stage::send, Stage::receive, Stage::read, Stage::eof, Stage::close}) {
for (const bool timeout : {false, true}) {
reset(watch);
fake_http::state.stop_at = stage;
fake_http::state.timeout = timeout;
result = get();
check(!result.ok && result.error == (timeout ? ERROR_TIMEOUT : ERROR_CANCELLED),
"pending operation or final completion reports the authoritative stop reason");
check(removed(), "stopped GET removes only its created output, including partial bytes");
check(closed(), "stopped request completes handle-closing lifetime handshake");
if (stage == Stage::read)
check(watch.received.load() == 7 && fake_http::state.pending_buffer_closes == 1,
"partial output is cleaned while pending read buffer survives close");
}
}
for (const auto stage : {Stage::send, Stage::receive, Stage::read}) {
for (const bool callback_error : {false, true}) {
reset(watch);
fake_http::state.fail_at = stage;
fake_http::state.callback_error = callback_error;
result = get();
check(!result.ok && result.error == ERROR_WINHTTP_CONNECTION_ERROR,
"immediate and callback API errors are reported");
check(removed() && closed(), "API errors clean up output and close registered request");
}
}
reset(watch);
watch.deadline_us.store(1);
result = get();
check(!result.ok && result.error == ERROR_TIMEOUT && fake_http::state.opens == 0 && removed(),
"already expired external deadline prevents network and output creation");
reset(watch);
watch.deadline_us.store(static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count()) + 60000000);
result = get(1024, 0);
check(!result.ok && result.error == ERROR_TIMEOUT && fake_http::state.opens == 0,
"internal timeout wins over a later external steady-clock deadline");
reset(watch);
watch.cancel.store(true);
watch.deadline_us.store(1);
result = get();
check(!result.ok && result.error == ERROR_CANCELLED && fake_http::state.opens == 0,
"explicit cancellation wins when both stop conditions hold");
reset(watch);
{
std::ofstream output(path, std::ios::binary);
output << "existing destination";
}
result = get();
check(!result.ok && contents(path) == "existing destination",
"CREATE_NEW failure preserves a pre-existing destination");
check(closed(), "existing-destination failure still closes the request");
std::filesystem::remove(path);
for (const bool content_length : {false, true}) {
reset(watch);
fake_http::state.content_length = content_length;
result = get(3);
check(!result.ok && result.error == ERROR_FILE_TOO_LARGE && removed() && closed(),
"announced and streamed oversized bodies remain rejected and cleaned up");
}
reset(watch);
fake_http::state.downgrade = true;
result = get();
check(!result.ok && result.error == ERROR_WINHTTP_REDIRECT_FAILED && removed() && closed(),
"HTTPS-to-HTTP redirects remain refused");
reset(watch);
fake_http::state.body.clear();
result = get();
check(!result.ok && result.error == ERROR_BAD_LENGTH && removed() && closed(),
"empty body remains rejected and its created output is removed");
reset(watch);
fake_http::state.stop_at = Stage::send;
const auto text = get_text(L"https://example.invalid/metadata", 1024, 5, L"test", &result, &watch);
check(!text && !result.ok && result.error == ERROR_CANCELLED && closed(),
"get_text forwards its Watch into the actual asynchronous GET");
if (failures) return 1;
std::cout << "HTTPS deadline/cancellation tests passed\n";
return 0;
}
+127 -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,30 @@ Download get(std::wstring_view url, const std::filesystem::path& destination,
}
if (!total) return fail(ERROR_BAD_LENGTH);
if (!FlushFileBuffers(output.value)) return fail(GetLastError());
async.close();
if (const auto error = stop()) return fail(error);
output.complete = true;
result.ok = true;
return result;
}
std::optional<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);
std::filesystem::remove(path, error); // discard a stale file left by an earlier process
const auto download = get(url, path, max_bytes, timeout_seconds, agent, watch);
if (result) *result = download;
std::optional<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,650 @@
#include "Extension/Assets/map_download.h"
#include "Extension/Assets/live_mods.h"
#include "Extension/Multiplayer/Session/monotonic_clock.h"
#include "Extension/Multiplayer/Net/protocol.h"
#include "Engine/Core/Log/logging.h"
#include "Engine/Vfs/https_download.h"
#include "Engine/Vfs/mod_catalog.h"
#include "Launcher/mod_manager.h"
#include "Extension/UI/Overlay/overlay.h"
#include <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};
constexpr auto use_download_deadline = static_cast<std::uint64_t>(-1);
std::atomic<std::uint64_t> active_deadline{use_download_deadline};
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{};
std::atomic<std::uint64_t> queued_stop_deadline{};
bool block_download{}, block_install{}, slow{}, transfer_error{}, fallback{};
dingosdk::MultiplayerModel session;
std::mutex messages_mutex, gate_mutex;
std::condition_variable gate;
std::vector<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(); }
std::uint64_t map_fetch_deadline() noexcept {
const auto active = fixture::active_deadline.load();
return active == fixture::use_download_deadline ? map_download::deadline.load() : active;
}
void hold_map_load(bool hold, std::string_view package) noexcept {
fixture::held.store(hold);
if (hold && package != "Team-Map-1.0.0") std::abort();
}
bool queue_command(std::string_view action, std::string_view argument, std::string_view password) {
if (action == "stop") ++fixture::stops;
if (action == "stop-map-fetch") {
const auto expected = map_download::deadline.load();
fixture::require(expected && argument == std::to_string(expected) && password.empty(),
"map stop carries the captured originating attempt deadline");
fixture::queued_stop_deadline.store(expected);
++fixture::stops;
}
if (action == "rejoin") ++fixture::rejoins;
return true;
}
std::string map_package_name(std::string_view folder, std::string_view version) {
return std::string(folder) + "-" + std::string(version);
}
}
namespace dingosdk::overlay {
void set_map_download_icon(const std::string&) noexcept {}
void notify(NoticeLevel, std::string title, std::string text) noexcept {
std::lock_guard lock(fixture::messages_mutex);
fixture::notices.push_back(title + ": " + text);
}
}
namespace dingosdk::logging {
bool enabled(Level) noexcept { return false; }
void write(Level, Context, Channel, std::string_view) noexcept {}
void write(Level, Channel, std::string_view) noexcept {}
}
namespace fixture {
namespace download = dingosdk::map_download;
using Stage = download::Stage;
void drain() {
release_download.store(true);
release_install.store(true);
gate.notify_all();
wait([] { return download::workers.load() == 0; }, "workers did not drain");
}
void reset() {
drain();
require(download::view().stage == Stage::idle, "previous lifecycle did not finish");
fs::remove_all(root / "Mods");
clock.store(1000000);
active_deadline.store(use_download_deadline);
downloading.store(false);
installing.store(false);
release_download.store(false);
release_install.store(false);
merging.store(false);
fetching.store(true);
held.store(false);
observed_cancel.store(false);
installed_cancel.store(false);
downloads.store(0);
installs.store(0);
applies.store(0);
stops.store(0);
queued_stop_deadline.store(0);
rejoins.store(0);
block_download = block_install = slow = transfer_error = fallback = false;
session = {};
session.active = true;
session.connected = false;
notices.clear();
http_budgets.clear();
archive.clear();
download::last_failed.clear();
}
void offer(std::uint64_t deadline = 0, bool update = false) {
dingosdk::multiplayer::MapNeed need{"Team-Map-1.0.0", "Test map", true, false,
update ? "0.9.0" : "", deadline};
download::offer(need);
wait([] { return download::asking(); }, "metadata did not reach confirmation");
wait([] { return download::workers.load() == 0; }, "metadata worker did not drain");
}
void start_apply() {
wait([] { return download::view().stage == Stage::applying; }, "did not install and reach applying");
drain();
download::tick();
require(applies.load() == 1 && held.load(), "apply starts once and holds session loading");
}
void applying() {
download::answer(true);
start_apply();
}
void joined() {
merging.store(false);
download::tick();
require(download::view().stage == Stage::joining, "completed merge advances to joining");
fetching.store(false);
session.connected = true;
clock.fetch_add(2500000);
download::tick();
require(download::view().stage == Stage::idle, "joined session clears lifecycle");
require(!held.load() && stops.load() == 0 && rejoins.load() == 0, "normal join does not leave or rejoin");
}
void idle(const char* reason, unsigned expected_stops = 1) {
require(download::view().stage == Stage::idle, reason);
drain();
require(stops.load() == expected_stops, "failure only stops its originating waiting session");
if (expected_stops)
require(queued_stop_deadline.load() == download::deadline.load(),
"queued map stop identifies the originating attempt for dispatch-time ownership validation");
download::tick();
require(held.load() == (merging.load() && download::apply_started.load()),
"failure holds map loading only while an uncancellable live merge drains");
require(archive.empty() || !fs::exists(archive), "partial archive removed");
}
void tests() {
reset();
offer();
applying();
require(downloads.load() == 1 && installs.load() == 1, "success downloads and installs exactly once");
joined();
reset();
merging.store(true);
offer();
download::answer(true);
wait([] { return download::view().stage == Stage::applying; }, "unrelated live merge blocked map offer");
drain();
download::tick();
require(applies.load() == 0 && held.load(), "map apply waits for unrelated live merge without rejecting offer");
merging.store(false);
download::tick();
require(applies.load() == 1, "map apply starts after unrelated merge drains");
joined();
reset();
block_download = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "download did not start for duplicate confirmation");
const auto first_transfer_deadline = download::transfer_until.load();
clock.fetch_add(policy::second_us);
download::answer(true);
require(download::transfer_until.load() == first_transfer_deadline && downloads.load() == 1 &&
download::workers.load() == 1,
"only winning confirmation claims a worker and publishes the transfer deadline");
release_download.store(true);
gate.notify_all();
start_apply();
joined();
reset();
block_download = true;
slow = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "slow download did not start");
clock.fetch_add(10 * 60 * policy::second_us);
download::watch.received.store(50);
download::tick();
require(download::view().stage == Stage::downloading && download::view().received == 50 && held.load(),
"progressing transfer remains active beyond old five minute deadline");
release_download.store(true);
gate.notify_all();
start_apply();
require(clock.load() > 5 * 60 * policy::second_us, "slow progressing transfer exceeds former five minute limit");
joined();
reset();
transfer_error = true;
offer();
download::answer(true);
wait([] { return download::view().stage == Stage::idle; }, "HTTP timeout did not fail");
idle("transfer timeout");
require(installs.load() == 0 && noticed("timed out"), "HTTP timeout cannot install");
transfer_error = false;
stops.store(0);
offer();
applying();
require(downloads.load() == 2, "timeout allows same package retry");
joined();
reset();
block_download = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "download did not start");
clock.store(download::deadline.load());
download::tick();
idle("active session deadline");
require(observed_cancel.load() && installs.load() == 0, "session expiry cancels active HTTP");
reset();
block_install = true;
offer();
download::answer(true);
wait([] { return installing.load(); }, "install did not start");
clock.store(download::deadline.load());
download::tick();
idle("install timeout");
require(installed_cancel.load() && applies.load() == 0 && noticed("could not be installed"), "installer observes cancellation");
reset();
block_install = true;
offer();
download::answer(true);
wait([] { return installing.load(); }, "install did not start for progress timeout");
clock.store(download::deadline.load());
drain();
idle("installer progress deadline");
require(installed_cancel.load() && applies.load() == 0,
"installer progress callback enforces session deadline without client tick");
reset();
offer();
applying();
clock.store(download::deadline.load());
download::tick();
idle("apply timeout");
require(merging.load() && rejoins.load() == 0, "late atomic merge must not join");
download::offer({"Team-Map-1.0.0", "Test map", true});
require(download::view().stage == Stage::idle && noticed("still finishing"), "busy live merge blocks retry");
merging.store(false);
download::tick();
require(rejoins.load() == 0 && !held.load(), "finished cancelled apply cannot rejoin and releases load hold");
reset();
offer();
download::answer(false);
idle("decline");
require(downloads.load() == 0 && noticed("cancelled"), "decline never downloads");
reset();
offer();
clock.store(download::ask_until.load() - 1);
download::tick();
require(download::asking(), "confirmation remains open one microsecond before deadline");
clock.fetch_add(1);
download::answer(true);
idle("confirmation exact boundary");
require(downloads.load() == 0 && noticed("two minutes"), "late click cannot bypass confirmation timeout");
reset();
offer();
clock.store(download::ask_until.load());
download::tick();
idle("confirmation tick timeout");
require(downloads.load() == 0 && noticed("two minutes"), "tick expires unanswered prompt");
reset();
const auto short_session = clock.load() + policy::confirmation_us / 2;
offer(short_session);
require(download::ask_until.load() == short_session, "confirmation is capped by existing session deadline");
clock.store(short_session);
download::answer(true);
idle("shorter session confirmation boundary");
require(downloads.load() == 0 && noticed("timed out"), "confirmation never extends existing session budget");
reset();
offer(clock.load() + policy::completion_reserve_us);
download::answer(true);
idle("completion reserve boundary");
require(downloads.load() == 0, "no transfer starts when only completion reserve remains");
reset();
block_download = true;
offer(clock.load() + policy::completion_reserve_us + 1);
download::answer(true);
wait([] { return downloading.load(); }, "last microsecond transfer did not start");
require(download::transfer_until.load() == clock.load() + 1, "near-boundary transfer keeps reserve");
clock.fetch_add(1);
download::tick();
idle("last transfer microsecond");
require(observed_cancel.load() && installs.load() == 0, "transfer exact boundary cancels without install");
reset();
block_download = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "HTTP did not start for cancel race");
download::answer(false);
require(download::watch.cancel.load(), "cancel flag is immediate");
download::tick();
require(held.load(), "cancelled HTTP keeps map loading held until worker drains");
download::offer({"Team-Map-1.0.0", "Test map", true});
require(downloads.load() == 1 && noticed("still finishing"), "retry cannot reuse a draining HTTP worker");
drain();
require(observed_cancel.load() && !fs::exists(archive) && installs.load() == 0, "cancel cleans archive without install");
// The same package is retriable after a user cancellation.
block_download = false;
offer();
applying();
require(downloads.load() == 2, "retry fetches same package again");
// The original user cancellation stopped its waiting attempt.
stops.store(0);
joined();
reset();
block_download = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "HTTP did not start for completion race");
clock.store(download::transfer_until.load());
// Return a successful HTTP response before tick notices its expired deadline.
drain();
download::tick();
require(download::view().stage == Stage::idle && installs.load() == 0,
"success racing the transfer deadline cannot reach installation");
idle("transfer completion race");
reset();
fallback = true;
offer();
applying();
require(downloads.load() == 2 && http_budgets.size() == 2, "404 selects newest fallback");
require(http_budgets[0] == policy::transfer_us, "initial request gets at most 27 minutes");
require(http_budgets[1] == policy::transfer_us - 60 * policy::second_us, "fallback loses first request's minute");
joined();
reset();
fallback = true;
offer(0, true);
download::answer(true);
wait([] { return download::view().stage == Stage::idle; }, "missing host version did not fail");
idle("host version consistency");
require(downloads.load() == 1 && installs.load() == 0, "version mismatch never falls back to another host version");
reset();
offer();
const auto original_deadline = download::deadline.load();
download::offer({"Team-Other-1.0.0", "Other map", true});
require(download::asking() && download::deadline.load() == original_deadline && stops.load() == 1,
"host changing maps does not replace in-flight request or extend its deadline");
stops.store(0);
download::answer(false);
idle("host map change");
reset();
block_download = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "download did not start for session replacement");
active_deadline.store(0);
download::tick();
idle("session identity changed", 0);
require(observed_cancel.load() && installs.load() == 0,
"download cancels when the active session no longer owns its deadline");
reset();
offer();
active_deadline.store(0);
fetching.store(false);
session.connected = true;
download::tick();
require(download::view().stage == Stage::idle && !held.load() && downloads.load() == 0 &&
stops.load() == 0 && rejoins.load() == 0,
"abandoned confirmation cancels without holding or stopping a fresh unrelated ordinary join");
download::answer(true);
require(downloads.load() == 0, "late confirmation cannot start download for abandoned session");
reset();
offer();
const auto abandoned_deadline = download::deadline.load();
clock.fetch_add(policy::second_us);
const auto fresh_deadline = clock.load() + policy::session_us;
active_deadline.store(fresh_deadline);
download::offer({"Team-Map-1.0.0", "Test map", true, false, "", fresh_deadline});
wait([] { return download::asking(); }, "fresh same-package need was not retaken");
drain();
require(download::deadline.load() == fresh_deadline && fresh_deadline != abandoned_deadline &&
download::transfer_until.load() == 0 && stops.load() == 0,
"fresh same-package attempt replaces stale confirmation without stopping its new session");
applying();
joined();
reset();
block_download = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "old download did not start for queued replacement");
clock.fetch_add(policy::second_us);
const auto queued_deadline = clock.load() + policy::session_us;
active_deadline.store(queued_deadline);
download::offer({"Team-Map-1.0.0", "Test map", true, false, "", queued_deadline});
download::tick();
require(download::view().stage == Stage::idle && download::watch.cancel.load() &&
downloads.load() == 1 && stops.load() == 0 && download::pending &&
download::pending->deadline_us == queued_deadline,
"fresh offer queues while cancelled worker drains without stopping replacement session");
drain();
require(observed_cancel.load() && installs.load() == 0 && !fs::exists(archive),
"old worker cleans partial archive before queued replacement can use its globals");
block_download = false;
download::tick();
wait([] { return download::asking(); }, "queued same-package need was not offered after drain");
drain();
require(download::deadline.load() == queued_deadline && download::transfer_until.load() == 0 &&
!download::pending && stops.load() == 0,
"queued attempt keeps fresh deadline and resets previous transfer budget");
applying();
require(downloads.load() == 2, "queued fresh attempt starts exactly one new transfer");
joined();
reset();
block_download = true;
offer();
download::answer(true);
wait([] { return downloading.load(); }, "old download did not start for stale pending need");
clock.fetch_add(policy::second_us);
const auto superseded_deadline = clock.load() + policy::session_us;
active_deadline.store(superseded_deadline);
download::offer({"Team-Map-1.0.0", "Test map", true, false, "", superseded_deadline});
active_deadline.store(0);
drain();
download::tick();
require(download::view().stage == Stage::idle && !download::pending && downloads.load() == 1 &&
stops.load() == 0 && !held.load(),
"queued need is discarded when its replacement session also ends before worker drain");
reset();
offer();
applying();
merging.store(false);
download::tick();
clock.store(download::join_at.load() + policy::installed_load_us - 1);
download::tick();
require(download::view().stage == Stage::joining, "installed map load retains its last microsecond");
clock.fetch_add(1);
download::tick();
idle("installed map load timeout");
require(noticed("still cannot load"), "installed map reports load failure at 30 seconds");
reset();
offer();
applying();
merging.store(false);
download::tick();
fetching.store(false);
session.active = false;
clock.fetch_add(2500000);
download::tick();
require(download::view().stage == Stage::idle && rejoins.load() == 1 && stops.load() == 0,
"successful install rejoins when the original session ended independently");
reset();
offer();
applying();
clock.store(download::deadline.load() - 2);
merging.store(false);
download::tick();
require(download::view().stage == Stage::joining, "near-deadline apply reaches joining");
clock.fetch_add(1);
session.connected = true;
fetching.store(false);
active_deadline.store(0);
clock.fetch_add(2);
download::tick();
require(download::view().stage == Stage::idle && !held.load() && stops.load() == 0 &&
rejoins.load() == 0 && !noticed("timed out"),
"join completed within session budget retires successfully even when next tick is after deadline");
reset();
download::tick();
require(!held.load() && downloads.load() == 0 && stops.load() == 0 && rejoins.load() == 0,
"ordinary join without download is untouched");
}
}
int main(int argc, char** argv) {
try {
fixture::require(argc == 2, "pass a fixture directory");
fixture::root = std::filesystem::absolute(argv[1]);
std::filesystem::create_directories(fixture::root);
fixture::tests();
fixture::drain();
std::filesystem::remove_all(fixture::root);
std::cout << "Production map download timeout lifecycle tests passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
// A bounded wait failure can leave detached production workers alive. Do not
// destroy their fixture globals while they may still be using them.
std::_Exit(1);
}
}
+185 -44
View File
@@ -6,15 +6,18 @@
#include "Engine/Vfs/mod_list.h"
#include "Engine/Vfs/thunderstore_package.h"
#include "Extension/Multiplayer/Net/protocol.h"
#include "Extension/Multiplayer/Session/monotonic_clock.h"
#include "Extension/UI/Overlay/overlay.h"
#include "Launcher/mod_manager.h"
#include <Windows.h>
#include <winhttp.h>
#include <algorithm>
#include <atomic>
#include <fstream>
#include <map>
#include <mutex>
#include <thread>
#include <utility>
namespace dingosdk::map_download {
namespace {
@@ -23,14 +26,16 @@ namespace package = thunderstore_package;
constexpr wchar_t agent[] = L"ReSkate";
constexpr char community[] = "reskate";
// A map is at most a few gigabytes (an archive of one holds four); the download may take as
// long as a slow line needs for that.
// Keep the existing package size restriction; time is bounded by the session policy.
constexpr std::uint64_t largest_download = 8ull << 30;
constexpr std::uint32_t download_seconds = 4 * 60 * 60;
namespace policy = multiplayer::map_fetch_policy;
std::mutex mutex;
std::atomic<Stage> stage{Stage::idle};
std::atomic<unsigned> workers{};
std::atomic<std::uint64_t> deadline{}, ask_until{}, transfer_until{};
multiplayer::MapNeed need; // under mutex, while not idle
std::optional<multiplayer::MapNeed> pending; // a fresh attempt waiting for cancelled work to drain
package::Name wanted; // under mutex
std::string version; // under mutex: the one to download
std::string newest; // under mutex
@@ -65,27 +70,56 @@ bool need_server() {
}
// The session that is waiting on the map is left: there is no map to wait for.
void leave_session() {
if (multiplayer::fetching_map()) multiplayer::queue_command("stop", "", "");
void leave_session(std::uint64_t expected = 0) {
const auto owned = expected ? expected : multiplayer::map_fetch_deadline();
if (multiplayer::fetching_map() && owned)
multiplayer::queue_command("stop-map-fetch", std::to_string(owned), "");
}
// Back to idle with the reason said, as the plain "map not installed" notice is.
void fail(std::string reason) {
void fail(std::string reason, bool retry = false) {
std::string text;
bool update{};
std::uint64_t failed_deadline{};
{
std::lock_guard lock(mutex);
if (stage.exchange(Stage::idle) == Stage::idle) return;
failed_deadline = deadline.load();
watch.cancel.store(true);
stop_install.store(true);
apply_wanted.store(false);
update = !need.have.empty();
text = "The " + host_word(need.server) + (need.moved ? " changed to " : " is on ") + need.map +
(update ? ", at another version (" + std::string(multiplayer::map_package_version(need.package)) + ") than the one installed on this PC (" + need.have + "). "
: ", which is not installed on this PC. ") + reason;
last_failed = need.package;
if (!retry) last_failed = need.package;
}
stage.store(Stage::idle);
leave_session();
if (multiplayer::map_fetch_deadline() == failed_deadline) leave_session(failed_deadline);
logging::log(logging::Level::warning, logging::Channel::assets, "Map download: {}", text);
overlay::notify(overlay::NoticeLevel::warning, update ? "Map version differs" : "Map not installed", std::move(text));
}
bool continuing() {
if (stage.load() == Stage::idle) return false;
if (policy::expired(deadline.load(), multiplayer::now_us())) {
const auto now = stage.load();
fail(now == Stage::installing || now == Stage::applying
? "The map could not be installed before the session timeout."
: "Map download timed out. Please try joining the server again.", true);
return false;
}
return !watch.cancel.load();
}
bool advance(Stage from, Stage to) {
if (!continuing()) return false;
return stage.compare_exchange_strong(from, to);
}
void ask() {
ask_until.store(std::min(deadline.load(), multiplayer::now_us() + policy::confirmation_us));
advance(Stage::checking, Stage::asking);
}
// The packages of the maps installed here, by level (package_of): forgotten when one is installed.
struct Known {
std::string package;
@@ -130,11 +164,12 @@ void check() {
const auto bytes = read_bytes(mods_root() / name.folder() / L"icon.png", 4 * 1024 * 1024);
overlay::set_map_download_icon(bytes);
} catch (const std::exception&) {}
stage.store(Stage::asking);
ask();
return;
}
https::Download result;
const auto answer = https::get_text(package::details_url(name), 512 * 1024, 20, agent, &result);
const auto answer = https::get_text(package::details_url(name), 512 * 1024, 20, agent, &result, &watch);
if (!continuing()) return;
if (!answer)
return fail(result.http_status == 404 ? "Its mod is not on Thunderstore. Install its map mod yourself and join again."
: "Thunderstore could not be reached to look for it.");
@@ -151,9 +186,9 @@ void check() {
}
// Its icon for the card: small, and not worth holding the question up for long.
if (!choice.icon.empty())
if (const auto png = https::get_text(std::wstring(choice.icon.begin(), choice.icon.end()), 2 * 1024 * 1024, 8, agent))
if (const auto png = https::get_text(std::wstring(choice.icon.begin(), choice.icon.end()), 2 * 1024 * 1024, 8, agent, nullptr, &watch))
overlay::set_map_download_icon(*png);
stage.store(Stage::asking);
ask();
} catch (const std::exception& failure) {
fail(std::string("Looking for it failed: ") + failure.what());
}
@@ -175,6 +210,7 @@ void fetch() {
const auto root = mods_root();
const auto folder = name.folder();
try {
if (!continuing()) return;
if (!off) {
wchar_t temp[MAX_PATH + 1]{};
const auto length = GetTempPathW(MAX_PATH, temp);
@@ -184,41 +220,61 @@ void fetch() {
fs::remove(archive, error);
watch.received.store(0);
watch.total.store(0);
auto result = https::get(package::download_url(name, get), archive, largest_download, download_seconds, agent, &watch);
// One transfer budget across fallback requests; leave time for installation and joining.
const auto transfer = transfer_until.load();
const auto download = [&](const std::string& selected) {
watch.deadline_us.store(transfer);
return https::get(package::download_url(name, selected), archive, largest_download,
static_cast<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 (policy::expired(transfer, multiplayer::now_us())) {
fs::remove(archive, error);
return fail("Map download timed out. Please try joining the server again.", true);
}
if (!advance(Stage::downloading, Stage::installing)) {
fs::remove(archive, error);
return;
}
try {
// (In the installed one's place when it is the same map at another version. The
// game may hold the old one's files open when it was skated this run: then it
// cannot be replaced until the game is closed.)
launcher_mods::install(root, archive, update, [](float) {}, stop_install, {folder, name.owner, true});
launcher_mods::install(root, archive, update, [](float) { continuing(); }, stop_install, {folder, name.owner, true});
} catch (const std::exception& failure) {
fs::remove(archive, error);
if (!continuing()) return;
return fail(update ? std::string("It could not be replaced while the game runs (") + failure.what() +
"). Close the game, update it from the launcher and join again."
: std::string("It could not be installed: ") + failure.what());
}
fs::remove(archive, error);
if (!continuing()) return;
{
std::lock_guard known(known_mutex);
known_packages.clear();
}
} else {
stage.store(Stage::installing);
if (!advance(Stage::downloading, Stage::installing)) return;
}
if (!continuing()) return;
// A map and nothing else: this way in is for maps.
std::error_code error;
if (!fs::exists(root / folder / L"reskate-levels.json", error) || !live_mods::only_maps(root / folder)) {
@@ -228,16 +284,18 @@ void fetch() {
return fail("Its package holds more than a map, so it is not installed from here. Install it from the launcher if you trust it.");
}
if (off || update) { // (a map at another version may have been switched off: it is wanted now)
if (!continuing()) return;
auto list = mods::scan_mods(mods::engine_data_root());
if (!list.issue.empty()) return fail("mods.json could not be read: " + list.issue);
for (auto& entry : list.entries)
if (same(entry.mod.name, folder)) entry.enabled = true;
mods::save_mod_order(list.root, list.entries);
}
if (!continuing()) return;
logging::log(logging::Level::info, logging::Channel::assets, "Map download: {} is in Mods; applying it.", folder);
apply_started.store(false);
apply_wanted.store(true);
stage.store(Stage::applying);
if (!advance(Stage::installing, Stage::applying)) apply_wanted.store(false);
} catch (const std::exception& failure) {
fail(std::string("It could not be installed: ") + failure.what());
}
@@ -245,10 +303,35 @@ void fetch() {
// One piece of work at a time, which the stage sees to: each is started by the move into its
// stage. Detached, as the live merge's thread is: the game may close while a map downloads.
void start(void (*work)()) { std::thread(work).detach(); }
void start(void (*work)()) {
++workers;
try {
std::thread([work] {
struct Finished { ~Finished() { --workers; } } finished;
work();
}).detach();
} catch (...) {
--workers;
throw;
}
}
} // namespace
void offer(const multiplayer::MapNeed& asked) {
std::unique_lock lock(mutex);
if (stage.load() != Stage::idle && asked.deadline_us && asked.deadline_us != deadline.load()) {
lock.unlock();
fail("Map download cancelled.", true);
lock.lock();
}
// A cancelled worker/atomic live merge must drain before its globals or files are reused.
if (stage.load() == Stage::idle && (workers.load() || (apply_started.load() && live_mods::busy()))) {
pending = asked;
overlay::notify(overlay::NoticeLevel::warning, "Map download",
"The previous map operation is still finishing. Waiting before starting this map.");
return;
}
pending.reset();
auto idle = Stage::idle;
const auto name = package::parse_name(asked.package);
// (Taken before anything is set up, so a second need while this one is in hand is dropped.)
@@ -256,10 +339,7 @@ void offer(const multiplayer::MapNeed& asked) {
// Another map is in hand (the session moved on while it came). One at a time: the one
// being fetched is finished, and the session, which now waits on this other one, is left.
bool other{};
{
std::lock_guard lock(mutex);
other = asked.package != need.package;
}
other = asked.package != need.package;
if (other) {
overlay::notify(overlay::NoticeLevel::warning, "Map changed again",
"The " + host_word(asked.server) + " moved on to " + asked.map + " while another map was downloading. Join again when it is done.");
@@ -268,7 +348,6 @@ void offer(const multiplayer::MapNeed& asked) {
return;
}
{
std::lock_guard lock(mutex);
need = asked;
if (!name || asked.package == last_failed) {
// (Said once already, or nothing this can fetch: the plain notice.)
@@ -287,23 +366,52 @@ void offer(const multiplayer::MapNeed& asked) {
}
overlay::set_map_download_icon({});
watch.cancel.store(false);
const auto now = multiplayer::now_us();
deadline.store(asked.deadline_us ? asked.deadline_us : now + policy::session_us);
ask_until.store(0);
transfer_until.store(0);
watch.deadline_us.store(deadline.load());
stop_install.store(false);
apply_wanted.store(false);
apply_started.store(false);
pick_download.store(true);
start(check);
try {
start(check);
} catch (...) {
lock.unlock();
fail("The map download could not be started. Please try joining again.", true);
}
}
void answer(bool download) noexcept {
try {
// (A click and a key can answer in the same moment: only the first does.)
auto asked = Stage::asking;
if (stage.compare_exchange_strong(asked, download ? Stage::downloading : Stage::idle)) {
if (download) start(fetch);
else leave_session();
} else if (asked == Stage::downloading && !download) {
watch.cancel.store(true);
if (!continuing()) return;
const auto now = multiplayer::now_us();
if (stage.load() == Stage::asking && policy::expired(ask_until.load(), now)) {
fail("The map download was not confirmed within two minutes. Please try joining again.", true);
return;
}
if (!download) {
if (stage.load() == Stage::asking || stage.load() == Stage::downloading)
fail("Map download cancelled.", true);
} else {
bool no_time{};
{
// Publish the stage and claim its worker together. Cancellation cannot allow
// a fresh offer to reuse the globals before this thread starts its worker.
std::lock_guard lock(mutex);
auto asked = Stage::asking;
if (!watch.cancel.load() && stage.compare_exchange_strong(asked, Stage::downloading)) {
transfer_until.store(policy::transfer_deadline(deadline.load(), now));
no_time = policy::expired(transfer_until.load(), now) && !installed_off;
if (!no_time) start(fetch);
}
}
if (no_time) fail("Map download timed out. Please try joining the server again.", true);
}
} catch (...) {
stage.store(Stage::idle);
fail("The map download could not be started. Please try joining again.", true);
}
}
@@ -345,10 +453,42 @@ View view() {
}
void tick() {
const auto now = stage.load();
std::optional<multiplayer::MapNeed> next;
{
std::lock_guard lock(mutex);
if (pending && pending->deadline_us != multiplayer::map_fetch_deadline()) pending.reset();
if (pending && stage.load() == Stage::idle && !workers.load() &&
!(apply_started.load() && live_mods::busy()))
next = std::exchange(pending, std::nullopt);
}
if (next) offer(*next);
auto now = stage.load();
if (now != Stage::idle) {
const auto session = multiplayer::model();
// Admission (or completed travel) retires the session deadline. Do not turn an
// already successful join into a timeout while waiting out the UI's settling delay.
if (now == Stage::joining && session.active && session.connected &&
!multiplayer::fetching_map() && !multiplayer::map_fetch_deadline()) {
std::lock_guard lock(mutex);
if (stage.compare_exchange_strong(now, Stage::idle)) last_failed.clear();
now = stage.load();
}
if (session.active && now != Stage::joining && multiplayer::map_fetch_deadline() != deadline.load())
fail("Map download cancelled.", true);
if (!session.active && session.status.find("stopped waiting for the map download") != std::string::npos)
fail("The server stopped waiting for the map download.", true);
if (continuing() && now == Stage::asking &&
policy::expired(ask_until.load(), multiplayer::now_us()))
fail("The map download was not confirmed within two minutes. Please try joining again.", true);
if (continuing() && now == Stage::downloading &&
policy::expired(transfer_until.load(), multiplayer::now_us()))
fail("Map download timed out. Please try joining the server again.", true);
now = stage.load();
}
// The session leaves the map alone until it has been applied (an update's level is one the
// game already has, and would load from files that are being replaced).
multiplayer::hold_map_load(now == Stage::downloading || now == Stage::installing || now == Stage::applying);
multiplayer::hold_map_load((now != Stage::idle && now != Stage::joining) ||
workers.load() || (apply_started.load() && live_mods::busy()), need_package());
if (now == Stage::applying) {
if (apply_wanted.load()) {
// (A merge of the player's own may be running: wait for it and start this one.)
@@ -371,24 +511,25 @@ void tick() {
return fail("It is installed, but was left out when the mods were applied (" + live_mods::status() + ").");
// The level list takes the new map on the next ticks, and the session, which has been
// trying the map all along, loads it then.
join_at.store(GetTickCount64());
stage.store(Stage::joining);
join_at.store(multiplayer::now_us());
advance(Stage::applying, Stage::joining);
return;
}
if (now == Stage::joining) {
const auto waited = GetTickCount64() - join_at.load();
if (waited < 2500) return;
const auto waited = multiplayer::now_us() - join_at.load();
if (waited < 2500000) return;
// Still waiting on it: the game has the mod and cannot find the map in it.
if (multiplayer::fetching_map()) {
if (waited > 30000) fail("It is installed, but the game still cannot load the map. Restart the game and join again.");
const auto session = multiplayer::model();
if (multiplayer::fetching_map() || (session.active && !session.connected)) {
if (waited >= policy::installed_load_us) fail("It is installed, but the game still cannot load the map. Restart the game and join again.");
return;
}
{
// Should it not load after all, it is said and not offered round and round.
std::lock_guard lock(mutex);
last_failed = need.package;
last_failed.clear();
}
stage.store(Stage::idle);
if (!continuing() || !stage.compare_exchange_strong(now, Stage::idle)) return;
// The session ended while the map came (the host left, the connection dropped): back to it.
if (!multiplayer::model().active && !multiplayer::queue_command("rejoin", "", ""))
overlay::notify(overlay::NoticeLevel::warning, "Map installed", "Join the session again from the server browser.");
+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"
@@ -226,9 +227,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.
+17 -9
View File
@@ -158,6 +158,8 @@ void stop(Session &s, std::string reason) {
s.map_label.clear();
s.map_package.clear();
s.fetching_since = 0;
s.map_fetch_deadline = 0;
map_fetch_deadline_flag.store(0);
fetching_map_flag.store(false, std::memory_order_relaxed);
s.world = 1;
s.travelling = false;
@@ -734,7 +736,10 @@ std::string take_removed_notice() { return std::exchange(session().removed_notic
std::optional<MapNeed> take_map_need() { return std::exchange(session().map_need, std::nullopt); }
void set_map_package_lookup(MapPackageLookup lookup) noexcept { map_package_lookup.store(lookup, std::memory_order_relaxed); }
bool fetching_map() noexcept { return fetching_map_flag.load(std::memory_order_relaxed); }
void hold_map_load(bool hold) noexcept { map_load_held.store(hold, std::memory_order_relaxed); }
std::uint64_t map_fetch_deadline() noexcept { return map_fetch_deadline_flag.load(); }
void hold_map_load(bool hold, std::string_view package) noexcept {
map_load_held.store(hold && !package.empty() ? map_hash(package) : 0);
}
MultiplayerChat chat() {
auto &s = session();
std::lock_guard lock(s.mutex);
@@ -773,6 +778,8 @@ bool observe_local_world(Session &s, const NativeFrame &local, std::string_view
} else if (s.travelling) {
if (local.ready && !s.awaiting_map && s.host_world_ready) {
s.travelling = false;
s.map_fetch_deadline = 0;
map_fetch_deadline_flag.store(0);
s.last_world_ready = 0;
for (auto &peer : active_peers(s)) {
peer.last_packet = now;
@@ -801,6 +808,10 @@ bool observe_local_world(Session &s, const NativeFrame &local, std::string_view
// Native loading can unload every local actor. Keep the connection in preflight
// until the selected map and a controllable skater are available.
bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoader loader, std::uint64_t now) {
if (map_fetch_policy::expired(s.map_fetch_deadline, now)) {
stop(s, "Map download timed out. Please try joining the server again.");
return false;
}
if (!s.awaiting_map)
return true;
// (While the map is being fetched the clock is held: see below.)
@@ -836,7 +847,7 @@ bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoade
// installed one's place), and loads when the two agree.
const auto have = std::string(other_map_version(s.map_package, host_map_package(s.join_destination)));
// (Nor while its files are being put in place and applied: hold_map_load.)
const bool held = map_load_held.load(std::memory_order_relaxed);
const bool held = !s.map_package.empty() && map_load_held.load() == map_hash(s.map_package);
const auto result = have.empty() && !held ? loader(s.join_destination, s.map_load_submitted, detail) : MapLoadResult::missing;
if (result == MapLoadResult::missing) {
const auto name = s.map_label.empty() ? world_level_name(world_destination_asset(s.join_destination)) : s.map_label;
@@ -849,16 +860,13 @@ bool prepare_join_map(Session &s, bool ready, std::string_view current, MapLoade
if (!s.map_package.empty()) {
if (!s.fetching_since) {
s.fetching_since = now;
if (!s.map_fetch_deadline) s.map_fetch_deadline = now + map_fetch_limit_us;
map_fetch_deadline_flag.store(s.map_fetch_deadline);
fetching_map_flag.store(true, std::memory_order_relaxed);
s.map_need = MapNeed{s.map_package, name, dedicated_host(s), s.travelling, have};
s.rejoin = Session::Rejoin{{s.host_id, s.secret}, s.password};
s.map_need = MapNeed{s.map_package, name, dedicated_host(s), s.travelling, have, s.map_fetch_deadline};
s.rejoin = Session::Rejoin{{s.host_id, s.secret}, s.password, s.map_fetch_deadline};
s.last_map_request = s.last_world_ready = 0; // say so at once
}
if (now - s.fetching_since > map_fetch_limit_us) {
s.leave_notice = where + ", which did not finish downloading in time. Install its map mod and join again.";
stop(s, s.leave_notice);
return false;
}
// The time a load gets starts when the map is here.
s.join_started = now;
if (s.travelling) s.travel_started = now;
+8 -4
View File
@@ -31,7 +31,8 @@ std::string take_removed_notice();
// for download instead (Extension/Assets/map_download.h): the session does not end. It tells
// the host the map is being fetched, which is given much longer than a load, and goes on
// trying the map, so it loads as soon as it is installed. Once per map; client thread.
// The downloader ends the session (queue_command "stop") when the player says no or the map
// The downloader ends its own attempt (queue_command "stop-map-fetch", with its deadline)
// when the player says no or the map
// cannot be had. Should the session end meanwhile for another reason,
// queue_command("rejoin", "", "") goes back to it, with the password it was joined with.
struct MapNeed {
@@ -42,15 +43,18 @@ struct MapNeed {
// The version of it installed here, when that is another than the host's: the host's is
// fetched in its place. Empty: the map is not installed.
std::string have;
// Local steady-clock deadline, shared with the downloader, never transmitted.
std::uint64_t deadline_us{};
};
std::optional<MapNeed> take_map_need();
// Whether a session is waiting on a map being fetched right now. Thread-safe.
bool fetching_map() noexcept;
// Held while a map is being downloaded, installed or applied: the session does not load the
// host's map meanwhile, and waits as it does for one that is missing. A map that is being
std::uint64_t map_fetch_deadline() noexcept;
// Held for the named package while it is being downloaded, installed or applied: unrelated
// joins are not held. The session waits as it does for a missing map. A map that is being
// replaced by another version is one the game already knows, so without this it would load as
// soon as the new files are in place, before the mods are applied again. Thread-safe.
void hold_map_load(bool hold) noexcept;
void hold_map_load(bool hold, std::string_view package) noexcept;
// How a host learns the package of the map it is on, to tell its guests: the level's asset in,
// the package (or nothing) out. Called on the client thread, often; it should be quick.
using MapPackageLookup = std::string (*)(std::string_view asset);
@@ -622,7 +622,7 @@ bool own_mark_command(std::string_view action) {
} // namespace
bool queue_command(std::string_view action, std::string_view argument, std::string_view password) {
if (launcher::offline_mode() && !own_mark_command(action)) return false;
if ((action != "host" && action != "host-config" && action != "join" && action != "rejoin" && action != "join-lobby" && action != "join-friend-lobby" && action != "stop" &&
if ((action != "host" && action != "host-config" && action != "join" && action != "rejoin" && action != "join-lobby" && action != "join-friend-lobby" && action != "stop" && action != "stop-map-fetch" &&
action != "distances" && action != "object-placement" && action != "object-limit" && action != "kick" && action != "clear-objects" &&
action != "nametags" && action != "chat-visible" && action != "chat-filter" &&
action != "nametag-distance" && action != "nametag-dots" && action != "nametags-friends" && action != "player-distance" && action != "direct-connections" && action != "pose-dump" && action != "vote" && action != "voice-chat" &&
@@ -875,7 +875,15 @@ std::string command(std::string_view action, std::string_view argument, std::str
if (action == "status")
return s.status + " | Players " + std::to_string(player_count(s)) + "/" +
std::to_string(s.capacity);
if (action == "stop") {
if (action == "stop-map-fetch") {
std::uint64_t expected{};
const auto parsed = std::from_chars(argument.data(), argument.data() + argument.size(), expected);
// A worker's queued cancellation may execute after a fresh join command.
if (parsed.ec != std::errc{} || parsed.ptr != argument.data() + argument.size() ||
!expected || expected != s.map_fetch_deadline)
return s.status;
}
if (action == "stop" || action == "stop-map-fetch") {
stop(s, "Multiplayer stopped.");
publish(s);
return s.status;
@@ -968,6 +976,8 @@ std::string command(std::string_view action, std::string_view argument, std::str
if (action == "rejoin") {
rejoin = std::exchange(s.rejoin, std::nullopt);
if (!rejoin) return "There is no session to go back to.";
if (map_fetch_policy::expired(rejoin->deadline_us, now_us()))
return "Map download timed out. Please try joining the server again.";
}
if (action != "host" && action != "join" && action != "echo" && !rejoin)
return "Unknown multiplayer action.";
@@ -1113,6 +1123,8 @@ std::string command(std::string_view action, std::string_view argument, std::str
s.host_id = invitation->steam_id;
s.awaiting_map = true;
s.join_started = now_us();
s.map_fetch_deadline = rejoin ? rejoin->deadline_us : 0;
map_fetch_deadline_flag.store(s.map_fetch_deadline);
s.status = "Connecting and checking the host's map...";
}
set_lobby_park_mode(s.mode == Mode::host || s.mode == Mode::join, s.mode == Mode::join);
@@ -262,11 +262,13 @@ struct Session {
// The map is not installed and is being fetched (take_map_need): since when, 0 for not.
// The host is told with what is sent while waiting (Packet::map_fetching).
std::uint64_t fetching_since{};
std::uint64_t map_fetch_deadline{};
std::optional<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()
@@ -475,14 +477,14 @@ std::string answer_server_poll(Session &s, std::size_t answer);
inline std::atomic<bool> server_vote_open_flag{};
inline std::atomic<MapPackageLookup> map_package_lookup{};
inline std::atomic<bool> fetching_map_flag{};
inline std::atomic<bool> map_load_held{};
inline std::atomic<std::uint64_t> map_fetch_deadline_flag{}, map_load_held{};
// A guest without the map is downloading it: the clock they are loading against starts over
// each time they say so, for as long as map_fetch_limit_us from the first time.
inline void guest_fetching(Peer &peer, std::uint64_t now) {
// (Only for a guest who is past the password.)
if (!peer.handshaken && !peer.map_authorized) return;
if (!peer.fetching_since) peer.fetching_since = now;
if (now - peer.fetching_since > map_fetch_limit_us) return;
if (map_fetch_policy::host_expired(peer.fetching_since, now)) return;
if (!peer.handshaken) peer.connected_at = now;
else if (peer.travel_since) peer.travel_since = now;
}
@@ -622,6 +622,16 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
continue;
}
const auto &p = *decoded;
if (s.mode == Mode::join && map_fetch_policy::expired(s.map_fetch_deadline, now)) {
stop(s, "Map download timed out. Please try joining the server again.");
return;
}
if (s.mode == Mode::host && link->fetching_since && (!link->handshaken || link->travel_since) &&
map_fetch_policy::host_expired(link->fetching_since, now)) {
disconnect(s, message.peer, "The server stopped waiting for the map download.");
if (s.mode == Mode::off) return;
continue;
}
// A dedicated server sends no poses; its roster and control traffic keep it alive.
if (dedicated_host(s) && message.peer == s.host_id) link->last_packet = now;
if (p.kind == PacketKind::world_state) {
@@ -636,6 +646,8 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
!newer_sequence(p.sequence, s.world_state_sequence)))
continue;
if (p.world > s.world) {
// Initial authorization during an automatic rejoin keeps the original budget.
const auto fetch_deadline = s.world ? 0 : s.map_fetch_deadline;
clear_world(s, now);
s.world = p.world;
s.travelling = s.awaiting_map = s.join_map_authorized = true;
@@ -646,6 +658,8 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
s.map_label.clear();
s.map_package.clear();
s.fetching_since = 0;
s.map_fetch_deadline = fetch_deadline;
map_fetch_deadline_flag.store(fetch_deadline);
fetching_map_flag.store(false, std::memory_order_relaxed);
s.map = 0;
s.map_load_submitted = false;
@@ -765,6 +779,7 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
s.map_package = p.map_package;
s.world = p.world;
s.join_map_authorized |= p.map_authorized;
link->last_packet = now;
if (new_challenge)
s.last_map_request = 0;
if (!s.join_map_authorized)
@@ -858,6 +873,10 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
link->world_ready = true;
link->travel_since = 0;
link->last_packet = now;
if (s.mode == Mode::join && message.peer == s.host_id && !s.awaiting_map) {
s.map_fetch_deadline = 0;
map_fetch_deadline_flag.store(0);
}
if (joined) s.join_backoff.joined(message.peer);
if (s.mode == Mode::host) {
send_required(s, message.peer, encode_wire(packet(s, PacketKind::welcome, now)));
@@ -1162,7 +1181,15 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
}
// Authorized arrivals get time for native loading; this has an absolute
// deadline, so requests cannot keep an unused slot reserved indefinitely.
if (s.mode == Mode::host && p.fetching_since && (!p.handshaken || p.travel_since) &&
map_fetch_policy::host_expired(p.fetching_since, now)) {
disconnect(s, p.member.id, "The server stopped waiting for the map download.");
if (s.mode == Mode::off) return;
continue;
}
const std::uint64_t handshake_timeout = p.map_authorized ? 180000000 : 8000000;
const bool fetching_from_host = s.mode == Mode::join && p.member.id == s.host_id &&
s.map_fetch_deadline && !map_fetch_policy::expired(s.map_fetch_deadline, now);
if (p.handshaken && (s.travelling || p.travel_since)) {
if (p.travel_since && now - p.travel_since > 180000000) {
disconnect(s, p.member.id, "A player could not finish loading the new map.");
@@ -1170,7 +1197,8 @@ void networking(Session &s, const NativeFrame &local, std::uint64_t now) {
}
continue;
}
if ((!p.handshaken && p.connected_at && now - p.connected_at > handshake_timeout) ||
if ((!p.handshaken && p.connected_at &&
(fetching_from_host ? now - p.last_packet > 10000000 : now - p.connected_at > handshake_timeout)) ||
(p.handshaken && now - p.last_packet > 10000000)) {
disconnect(s, p.member.id, "A player timed out while waiting for gameplay data.");
if (s.mode == Mode::off)
@@ -0,0 +1,89 @@
#include "Extension/Multiplayer/Net/map_fetch_policy.h"
#include "Extension/Multiplayer/Net/protocol.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(dingosdk::multiplayer::map_fetch_limit_us == policy::session_us,
"host protocol and local downloader share the session budget");
require(policy::transfer_us == 1620 * policy::second_us, "27 minute transfer");
require(policy::completion_reserve_us == 180 * policy::second_us, "3 minute completion reserve");
require(policy::confirmation_us == 120 * policy::second_us, "2 minute confirmation");
require(policy::installed_load_us == 30 * policy::second_us, "30 second installed-map load");
require(policy::transfer_us + policy::completion_reserve_us == policy::session_us, "budgets partition session");
require(!policy::expired(0, 0) && !policy::expired(0, std::numeric_limits<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;
}
}
+79
View File
@@ -1460,6 +1460,85 @@ void map_checks() {
pair.run(1);
check(pair.guest.mode == Mode::off, "Host departure left a pending map join active");
}
{
map_package_lookup.store(+[](std::string_view) { return std::string("Team-Map-1.0.0"); });
MapPair pair;
MapPair::missing = true;
pair.run(20);
const auto deadline = pair.guest.map_fetch_deadline;
check(deadline && pair.guest.map_need && pair.guest.map_need->deadline_us == deadline,
"Downloader did not receive the session's authoritative deadline");
pair.now += 180000001;
auto request = packet(pair.guest, PacketKind::map_request, pair.now);
request.map = pair.host.map;
request.map_fetching = true;
send_required(pair.guest, pair.host.host_id, encode_wire(request));
networking(pair.host, pair.host_frame, pair.now);
networking(pair.guest, NativeFrame{}, pair.now);
check(pair.guest.mode == Mode::join && pair.guest.map_fetch_deadline == deadline,
"An active download was disconnected by the ordinary host-link handshake timeout");
check(!prepare_join_map(pair.guest, false, pair.current, MapPair::loader, deadline - 1) &&
pair.guest.mode == Mode::join,
"Map fetch expired before its deadline");
check(!prepare_join_map(pair.guest, false, pair.current, MapPair::loader, deadline) &&
pair.guest.mode == Mode::off,
"Map fetch survived its exact deadline");
map_package_lookup.store(nullptr);
}
{
map_package_lookup.store(+[](std::string_view) { return std::string("Team-Map-1.0.0"); });
MapPair pair;
MapPair::missing = true;
pair.run(20);
const auto deadline = pair.guest.map_fetch_deadline;
MapPair::missing = false;
pair.current = pair.host.map_name;
const auto now = deadline - 1;
check(prepare_join_map(pair.guest, true, pair.current, MapPair::loader, now),
"Installed map could not finish loading within the deadline");
pair.guest.map = map_hash(pair.current);
pair.guest.map_name = pair.current;
find_peer(pair.guest, pair.host.host_id)->last_packet = now; // ongoing metadata heartbeats
check(pair.guest.map_fetch_deadline == deadline,
"Native loading completion prematurely discarded the join deadline");
networking(pair.guest, pair.host_frame, now);
networking(pair.host, pair.host_frame, now);
networking(pair.guest, pair.host_frame, now);
check(find_peer(pair.guest, pair.host.host_id)->handshaken && !pair.guest.map_fetch_deadline,
"Welcome did not retire a successfully completed download deadline");
check(prepare_join_map(pair.guest, true, pair.current, MapPair::loader, deadline + 1),
"A successfully joined player was disconnected by a retired deadline");
map_package_lookup.store(nullptr);
}
{
MapPair pair;
pair.run(20);
hold_map_load(true, "Team-Unrelated-1.0.0");
check(!prepare_join_map(pair.guest, false, pair.current, MapPair::loader, pair.now + 500000) &&
pair.guest.mode == Mode::join && pair.guest.map_load_submitted,
"An abandoned download blocked an unrelated ordinary map load");
hold_map_load(false, "");
}
{
auto &waiting = session();
stop(waiting, "Map cancellation fixture");
waiting.mode = Mode::join;
waiting.map_fetch_deadline = 100;
check(queue_command("stop-map-fetch", "100", ""), "Map cancellation command was not queued");
std::unique_ptr<PrivateRequest> cancellation;
{
std::lock_guard lock(waiting.request_mutex);
cancellation = std::move(waiting.requests.back());
waiting.requests.pop_back();
}
// A fresh join executes before the old worker's cancellation is dispatched.
waiting.map_fetch_deadline = 200;
command(cancellation->action, cancellation->argument, cancellation->password);
check(waiting.mode == Mode::join && waiting.map_fetch_deadline == 200,
"A queued cancellation stopped a fresh map-fetch attempt");
command("stop-map-fetch", "200");
check(waiting.mode == Mode::off, "An owned cancellation did not stop its pending join");
}
std::cout << "Automatic map joining: codec, same/different maps, password-before-load, slow loading, "
"skater readiness, missing maps, cancellation, timeout and host departure passed.\n";
}
+22
View File
@@ -119,6 +119,28 @@ int main() {
make_zip(sources / L"v2.zip", {{"Cool Park/layout.toc", "toc v2"}});
check(install(mods, sources / L"v2.zip", true) == "Cool Park" && read(mods / L"Cool Park" / L"layout.toc") == "toc v2",
"Replacing installs the new copy");
{
std::atomic<bool> cancel{};
unsigned callbacks{};
make_zip(sources / L"cancelled.zip", {{"Cool Park/layout.toc", "must not replace"}});
const auto issue = error_of([&] {
launcher_mods::install(mods, sources / L"cancelled.zip", true,
[&](float) { if (++callbacks == 2) cancel.store(true); }, cancel);
});
check(issue == "Install cancelled." && callbacks == 2,
"Cancellation at the final safe installation boundary was ignored");
check(read(mods / L"Cool Park" / L"layout.toc") == "toc v2" &&
!fs::exists(mods / L".reskate-install"),
"Cancelled replacement damaged the existing mod or left staging files");
write(sources / L"cancelled folder" / L"Cool Park" / L"layout.toc", "must not replace");
cancel.store(false);
callbacks = 0;
check(error_of([&] {
launcher_mods::install(mods, sources / L"cancelled folder", true,
[&](float) { if (++callbacks == 2) cancel.store(true); }, cancel);
}) == "Install cancelled." && read(mods / L"Cool Park" / L"layout.toc") == "toc v2",
"Cancelled folder replacement did not preserve the installed mod");
}
// Hostile or unrelated archives.
make_zip(sources / L"evil.zip", {{"../evil.txt", "x"}, {"layout.toc", "toc"}});
+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
@@ -81,6 +81,21 @@ CachyOS: the launcher, the server browser, joining a public server and hosting a
custom map and then join a server on another map, the screen can stay black
([#31](https://github.com/Dingo-Shenanigans/ReSkate/issues/31)): load the server's map first.
### Multiplayer map downloads
Missing multiplayer custom maps can be downloaded from Thunderstore after confirmation.
The confirmation expires after two minutes. Checking, downloading, installing, applying and
joining share one 30-minute budget; HTTP transfers get at most 27 minutes and leave three minutes
for completion (confirmation and metadata checks reduce the available transfer time).
Dedicated servers and player-hosted lobbies enforce the same overall limit on their own
monotonic clocks. Ordinary map loading still gets 180 seconds; loading after a live installation
still gets 30 seconds, capped by the remaining budget.
Timeouts cancel HTTP transfers and remove incomplete temporary downloads. Installation stops
before replacing an existing mod when cancellation is observed. A filesystem commit or live
merge already in progress finishes safely rather than being forcibly interrupted; it cannot
restart a timed-out join. Retry after that work has finished, without restarting ReSkate.
### Controls
| Key | Opens |
+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_;
}
@@ -1096,6 +1096,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) {
@@ -1793,6 +1796,11 @@ void Host::tick(std::uint64_t now) {
auto &g = *(it++)->second;
// Authorized arrivals get time for loading; this deadline is absolute, so
// requests cannot hold an unused slot indefinitely.
if (g.fetching_since && (!g.handshaken || g.travel_since) &&
map_fetch_policy::host_expired(g.fetching_since, now_)) {
drop(g.member.id, "The server stopped waiting for the map download.");
continue;
}
const std::uint64_t handshake_timeout = g.map_authorized ? 180000000 : 8000000;
if (g.handshaken && g.travel_since) {
if (now_ - g.travel_since > 180000000) drop(g.member.id, "Could not finish loading the new map.");