mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-17 11:57:01 +02:00
Merge pull request #225 from Jean-LucTch/dev
Fix multiplayer custom map download and join timeout handling
This commit is contained in:
20 files changed
+1640
-84
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
// player is asked; on yes the package is downloaded, installed into Mods and applied as a live
|
||||
// mod (live_mods.h). The session stays connected meanwhile and loads the map once it is there;
|
||||
// on no, or when the map cannot be had, the session is left.
|
||||
// Checking, confirmation, transfer, installation, applying and joining share the session's
|
||||
// local monotonic 30-minute deadline. Confirmation gets two minutes; transfers at most 27,
|
||||
// leaving three for completion. A cancelled worker drains before another attempt can begin.
|
||||
//
|
||||
// Only maps come in this way. The package has to be listed as a map in ReSkate's Thunderstore
|
||||
// community, not deprecated and not rejected by its moderators; what is installed has to be a
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
|
||||
namespace dingosdk::multiplayer::map_fetch_policy {
|
||||
inline constexpr std::uint64_t second_us = 1000000;
|
||||
inline constexpr std::uint64_t session_us = 30ull * 60 * second_us;
|
||||
inline constexpr std::uint64_t transfer_us = 27ull * 60 * second_us;
|
||||
inline constexpr std::uint64_t completion_reserve_us = 3ull * 60 * second_us;
|
||||
inline constexpr std::uint64_t confirmation_us = 2ull * 60 * second_us;
|
||||
inline constexpr std::uint64_t installed_load_us = 30ull * second_us;
|
||||
|
||||
// All timestamps belong to one process's monotonic clock; none go on the wire.
|
||||
constexpr bool expired(std::uint64_t deadline, std::uint64_t now) noexcept {
|
||||
return deadline && now >= deadline;
|
||||
}
|
||||
constexpr std::uint64_t remaining(std::uint64_t deadline, std::uint64_t now) noexcept {
|
||||
return now < deadline ? deadline - now : 0;
|
||||
}
|
||||
constexpr std::uint64_t transfer_deadline(std::uint64_t overall, std::uint64_t now) noexcept {
|
||||
const auto available = remaining(overall, now);
|
||||
return now + std::min(transfer_us, available > completion_reserve_us ? available - completion_reserve_us : 0);
|
||||
}
|
||||
constexpr bool host_expired(std::uint64_t started, std::uint64_t now) noexcept {
|
||||
return started && now >= started && now - started >= session_us;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
#pragma once
|
||||
#include "map_fetch_policy.h"
|
||||
#include "Extension/Multiplayer/Remote/cosmetics.h"
|
||||
#include "Extension/Multiplayer/Session/peer_slots.h"
|
||||
#include "Extension/Multiplayer/Session/object_state.h"
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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"}});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.");
|
||||
|
||||
Reference in new issue
Block a user