mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-20 20:01:09 +02:00
ETC2 uploads read bufferRowLength/bufferImageHeight instead of assuming packed rows. MakeFileObject retains the path field. The main-thread pump caches its JNI attach, but not the GetJNIEnv fallback, so a VM swap retries. An idle launch-broker drain skips both locks.
412 lines
14 KiB
C++
412 lines
14 KiB
C++
#include "runtime/external_launch_broker.h"
|
|
|
|
#include "runtime/single_instance_lock.h"
|
|
|
|
#include <gtest/gtest.h>
|
|
#include <sys/socket.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/un.h>
|
|
#include <unistd.h>
|
|
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <condition_variable>
|
|
#include <cstddef>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace mocktail {
|
|
namespace runtime {
|
|
namespace {
|
|
|
|
RobloxExperienceLaunchRequest Request(int64_t place_id,
|
|
std::string game_instance_id = {}) {
|
|
const std::string json =
|
|
game_instance_id.empty()
|
|
? "{\"placeId\":" + std::to_string(place_id) + "}"
|
|
: "{\"gameInstanceId\":\"" + game_instance_id +
|
|
"\",\"placeId\":" + std::to_string(place_id) + "}";
|
|
RobloxExperienceLaunchRequest request;
|
|
EXPECT_TRUE(ParseRobloxExperienceLaunchJson(json, &request).ok());
|
|
return request;
|
|
}
|
|
|
|
struct CaptureProbe {
|
|
std::vector<RobloxExperienceLaunchRequest> requests;
|
|
bool reject_next = false;
|
|
};
|
|
|
|
Status Capture(void* context, const RobloxExperienceLaunchRequest& request) {
|
|
auto* probe = static_cast<CaptureProbe*>(context);
|
|
if (probe == nullptr) {
|
|
return Status::Error(StatusCode::kInvalidArgument, "capture probe is null");
|
|
}
|
|
if (probe->reject_next) {
|
|
probe->reject_next = false;
|
|
return Status::Error(StatusCode::kUnavailable,
|
|
"capture sink is temporarily full");
|
|
}
|
|
probe->requests.push_back(request);
|
|
return Status::Ok();
|
|
}
|
|
|
|
class ExternalLaunchBrokerTest : public ::testing::Test {
|
|
protected:
|
|
void SetUp() override {
|
|
char pattern[] = "/tmp/mocktail-external-launch-test-XXXXXX";
|
|
char* created = mkdtemp(pattern);
|
|
ASSERT_NE(created, nullptr);
|
|
directory_ = created;
|
|
ASSERT_EQ(chmod(directory_.c_str(), 0700), 0);
|
|
options_.socket_path = directory_ / "broker.sock";
|
|
options_.forwarding_timeout_ms = 1000;
|
|
}
|
|
|
|
void TearDown() override {
|
|
if (broker_ != nullptr) {
|
|
ClearActiveExternalLaunchBroker(broker_.get());
|
|
EXPECT_TRUE(broker_->Shutdown().ok());
|
|
broker_.reset();
|
|
}
|
|
std::error_code ignored;
|
|
std::filesystem::remove_all(directory_, ignored);
|
|
}
|
|
|
|
void Start(
|
|
std::size_t maximum_pending_launches = kMaximumPendingExternalLaunches) {
|
|
options_.maximum_pending_launches = maximum_pending_launches;
|
|
const Status status =
|
|
ExternalLaunchBroker::StartOwnerAfterLockAcquired(options_, &broker_);
|
|
ASSERT_TRUE(status.ok()) << status.message();
|
|
}
|
|
|
|
std::filesystem::path directory_;
|
|
ExternalLaunchBrokerOptions options_;
|
|
std::shared_ptr<ExternalLaunchBroker> broker_;
|
|
};
|
|
|
|
TEST_F(ExternalLaunchBrokerTest, QueuesAndDrainsInitialRequestsInFifoOrder) {
|
|
Start();
|
|
ASSERT_TRUE(broker_->QueueInitialRequest(Request(1001)).ok());
|
|
ASSERT_TRUE(broker_->QueueInitialRequest(Request(1002, "server-two")).ok());
|
|
|
|
CaptureProbe probe;
|
|
const Status status =
|
|
broker_->Drain({&probe, Capture}, kMaximumPendingExternalLaunches);
|
|
ASSERT_TRUE(status.ok()) << status.message();
|
|
ASSERT_EQ(probe.requests.size(), 2u);
|
|
EXPECT_EQ(probe.requests[0].place_id, 1001);
|
|
EXPECT_EQ(probe.requests[1].place_id, 1002);
|
|
EXPECT_EQ(probe.requests[1].game_instance_id, "server-two");
|
|
EXPECT_EQ(broker_->pending_launch_count(), 0u);
|
|
}
|
|
|
|
TEST_F(ExternalLaunchBrokerTest, QueuesControlledStartBeforeListenerAccepts) {
|
|
const RobloxExperienceLaunchRequest initial = Request(1501, "first-click");
|
|
const Status start_status = ExternalLaunchBroker::StartOwnerAfterLockAcquired(
|
|
options_, &broker_, &initial);
|
|
ASSERT_TRUE(start_status.ok()) << start_status.message();
|
|
ASSERT_TRUE(ExternalLaunchBroker::ForwardToOwner(
|
|
options_, Request(1502, "second-click"))
|
|
.ok());
|
|
|
|
CaptureProbe probe;
|
|
ASSERT_TRUE(broker_->Drain({&probe, Capture}, 2).ok());
|
|
ASSERT_EQ(probe.requests.size(), 2u);
|
|
EXPECT_EQ(probe.requests[0].game_instance_id, "first-click");
|
|
EXPECT_EQ(probe.requests[1].game_instance_id, "second-click");
|
|
}
|
|
|
|
TEST_F(ExternalLaunchBrokerTest, IgnoresProcessLocalXdgRuntimeDirectory) {
|
|
const char* previous = std::getenv("XDG_RUNTIME_DIR");
|
|
const bool had_previous = previous != nullptr;
|
|
const std::string previous_value = had_previous ? previous : "";
|
|
ASSERT_EQ(setenv("XDG_RUNTIME_DIR", directory_.c_str(), 1), 0);
|
|
|
|
std::filesystem::path resolved;
|
|
const Status status = ResolveExternalLaunchSocketPath(true, &resolved);
|
|
|
|
if (had_previous) {
|
|
EXPECT_EQ(setenv("XDG_RUNTIME_DIR", previous_value.c_str(), 1), 0);
|
|
} else {
|
|
EXPECT_EQ(unsetenv("XDG_RUNTIME_DIR"), 0);
|
|
}
|
|
ASSERT_TRUE(status.ok()) << status.message();
|
|
EXPECT_NE(resolved.parent_path(), directory_);
|
|
EXPECT_NE(resolved.filename().string().find(
|
|
CurrentExecutableInstanceKey()),
|
|
std::string::npos);
|
|
}
|
|
|
|
TEST_F(ExternalLaunchBrokerTest, ForwardsNormalizedRequestAndWaitsForOwnerAck) {
|
|
Start();
|
|
const Status status = ExternalLaunchBroker::ForwardToOwner(
|
|
options_, Request(2001, "private-instance"));
|
|
ASSERT_TRUE(status.ok()) << status.message();
|
|
ASSERT_EQ(broker_->pending_launch_count(), 1u);
|
|
|
|
CaptureProbe probe;
|
|
ASSERT_TRUE(broker_->Drain({&probe, Capture}, 1).ok());
|
|
ASSERT_EQ(probe.requests.size(), 1u);
|
|
EXPECT_EQ(probe.requests[0].place_id, 2001);
|
|
EXPECT_EQ(probe.requests[0].game_instance_id, "private-instance");
|
|
}
|
|
|
|
TEST_F(ExternalLaunchBrokerTest, RetriesWhileOwnerCompletesControlledStart) {
|
|
EXPECT_EQ(ExternalLaunchBrokerOptions{}.forwarding_timeout_ms, 30000);
|
|
Status forwarded = Status::Error(StatusCode::kUnavailable, "not attempted");
|
|
const RobloxExperienceLaunchRequest request = Request(2501);
|
|
const ExternalLaunchBrokerOptions client_options = options_;
|
|
std::thread client([&forwarded, &request, &client_options]() {
|
|
forwarded = ExternalLaunchBroker::ForwardToOwner(client_options, request);
|
|
});
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
|
|
Start();
|
|
client.join();
|
|
|
|
ASSERT_TRUE(forwarded.ok()) << forwarded.message();
|
|
EXPECT_EQ(broker_->pending_launch_count(), 1u);
|
|
}
|
|
|
|
TEST_F(ExternalLaunchBrokerTest, FailsClosedWhenOwnerQueueIsFull) {
|
|
Start(1);
|
|
ASSERT_TRUE(broker_->QueueInitialRequest(Request(3001)).ok());
|
|
|
|
const Status local_status = broker_->QueueInitialRequest(Request(3002));
|
|
EXPECT_FALSE(local_status.ok());
|
|
EXPECT_EQ(local_status.code(), StatusCode::kUnavailable);
|
|
|
|
const Status forwarded_status =
|
|
ExternalLaunchBroker::ForwardToOwner(options_, Request(3003));
|
|
EXPECT_FALSE(forwarded_status.ok());
|
|
EXPECT_EQ(forwarded_status.code(), StatusCode::kUnavailable);
|
|
EXPECT_EQ(broker_->pending_launch_count(), 1u);
|
|
}
|
|
|
|
TEST_F(ExternalLaunchBrokerTest, RequeuesAtFrontWhenDownstreamIsBusy) {
|
|
Start();
|
|
ASSERT_TRUE(broker_->QueueInitialRequest(Request(4001)).ok());
|
|
ASSERT_TRUE(broker_->QueueInitialRequest(Request(4002)).ok());
|
|
|
|
CaptureProbe probe;
|
|
probe.reject_next = true;
|
|
const Status first = broker_->Drain({&probe, Capture}, 2);
|
|
EXPECT_FALSE(first.ok());
|
|
EXPECT_EQ(first.code(), StatusCode::kUnavailable);
|
|
EXPECT_EQ(broker_->pending_launch_count(), 2u);
|
|
|
|
ASSERT_TRUE(broker_->Drain({&probe, Capture}, 2).ok());
|
|
ASSERT_EQ(probe.requests.size(), 2u);
|
|
EXPECT_EQ(probe.requests[0].place_id, 4001);
|
|
EXPECT_EQ(probe.requests[1].place_id, 4002);
|
|
}
|
|
|
|
TEST_F(ExternalLaunchBrokerTest, RejectsMalformedWirePacketWithoutQueueing) {
|
|
Start();
|
|
const int peer = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0);
|
|
ASSERT_GE(peer, 0);
|
|
sockaddr_un address{};
|
|
address.sun_family = AF_UNIX;
|
|
const std::string path = options_.socket_path.string();
|
|
ASSERT_LT(path.size(), sizeof(address.sun_path));
|
|
std::memcpy(address.sun_path, path.c_str(), path.size() + 1);
|
|
const socklen_t address_length =
|
|
static_cast<socklen_t>(offsetof(sockaddr_un, sun_path) + path.size() + 1);
|
|
ASSERT_EQ(connect(peer, reinterpret_cast<const sockaddr*>(&address),
|
|
address_length),
|
|
0);
|
|
constexpr std::array<char, 5> kMalformed = {'w', 'r', 'o', 'n', 'g'};
|
|
ASSERT_EQ(send(peer, kMalformed.data(), kMalformed.size(), MSG_NOSIGNAL),
|
|
static_cast<ssize_t>(kMalformed.size()));
|
|
std::array<unsigned char, 5> reply{};
|
|
ASSERT_EQ(recv(peer, reply.data(), reply.size(), 0),
|
|
static_cast<ssize_t>(reply.size()));
|
|
close(peer);
|
|
|
|
EXPECT_EQ(reply[0], 'M');
|
|
EXPECT_EQ(reply[1], 'T');
|
|
EXPECT_EQ(reply[2], 'L');
|
|
EXPECT_EQ(reply[3], '1');
|
|
EXPECT_EQ(reply[4], 1);
|
|
EXPECT_EQ(broker_->pending_launch_count(), 0u);
|
|
}
|
|
|
|
TEST_F(ExternalLaunchBrokerTest, ReplacesOnlyAnOwnedStaleSocket) {
|
|
const int stale = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0);
|
|
ASSERT_GE(stale, 0);
|
|
sockaddr_un address{};
|
|
address.sun_family = AF_UNIX;
|
|
const std::string path = options_.socket_path.string();
|
|
ASSERT_LT(path.size(), sizeof(address.sun_path));
|
|
std::memcpy(address.sun_path, path.c_str(), path.size() + 1);
|
|
const socklen_t address_length =
|
|
static_cast<socklen_t>(offsetof(sockaddr_un, sun_path) + path.size() + 1);
|
|
ASSERT_EQ(
|
|
bind(stale, reinterpret_cast<const sockaddr*>(&address), address_length),
|
|
0);
|
|
close(stale);
|
|
|
|
Start();
|
|
EXPECT_TRUE(std::filesystem::is_socket(options_.socket_path));
|
|
ASSERT_TRUE(broker_->Shutdown().ok());
|
|
EXPECT_FALSE(std::filesystem::exists(options_.socket_path));
|
|
}
|
|
|
|
TEST_F(ExternalLaunchBrokerTest, PreservesNonSocketEndpointAndRefusesStart) {
|
|
std::ofstream endpoint(options_.socket_path);
|
|
endpoint << "not a socket";
|
|
endpoint.close();
|
|
|
|
const Status status =
|
|
ExternalLaunchBroker::StartOwnerAfterLockAcquired(options_, &broker_);
|
|
EXPECT_FALSE(status.ok());
|
|
EXPECT_EQ(status.code(), StatusCode::kFailedPrecondition);
|
|
EXPECT_TRUE(std::filesystem::is_regular_file(options_.socket_path));
|
|
}
|
|
|
|
TEST_F(ExternalLaunchBrokerTest, GlobalAttachmentDrainsAndRequiresOneOwner) {
|
|
Start();
|
|
ASSERT_TRUE(InstallActiveExternalLaunchBroker(broker_).ok());
|
|
const Status duplicate = InstallActiveExternalLaunchBroker(broker_);
|
|
EXPECT_FALSE(duplicate.ok());
|
|
EXPECT_EQ(duplicate.code(), StatusCode::kFailedPrecondition);
|
|
ASSERT_TRUE(broker_->QueueInitialRequest(Request(5001)).ok());
|
|
|
|
CaptureProbe probe;
|
|
ASSERT_TRUE(DrainActiveExternalLaunchRequests({&probe, Capture}, 1).ok());
|
|
ASSERT_EQ(probe.requests.size(), 1u);
|
|
EXPECT_EQ(probe.requests[0].place_id, 5001);
|
|
|
|
ClearActiveExternalLaunchBroker(broker_.get());
|
|
EXPECT_EQ(GetActiveExternalLaunchBroker(), nullptr);
|
|
}
|
|
|
|
// The host main loop drains the broker on every tick and is almost always
|
|
// idle. An idle drain must observe the empty queue without waiting for a
|
|
// dispatch already in flight, or one slow launch stalls the whole loop.
|
|
TEST_F(ExternalLaunchBrokerTest, IdleDrainDoesNotWaitForAnInFlightDispatch) {
|
|
Start();
|
|
ASSERT_TRUE(broker_->QueueInitialRequest(Request(7001)).ok());
|
|
|
|
std::mutex gate_mutex;
|
|
std::condition_variable gate;
|
|
bool released = false;
|
|
std::atomic<bool> dispatching{false};
|
|
|
|
struct BlockingProbe {
|
|
std::mutex* gate_mutex;
|
|
std::condition_variable* gate;
|
|
bool* released;
|
|
std::atomic<bool>* dispatching;
|
|
} blocking{&gate_mutex, &gate, &released, &dispatching};
|
|
|
|
const ExternalLaunchSink blocking_sink{
|
|
&blocking,
|
|
[](void* context, const RobloxExperienceLaunchRequest&) -> Status {
|
|
auto* probe = static_cast<BlockingProbe*>(context);
|
|
probe->dispatching->store(true, std::memory_order_release);
|
|
std::unique_lock<std::mutex> lock(*probe->gate_mutex);
|
|
probe->gate->wait(lock, [probe] { return *probe->released; });
|
|
return Status::Ok();
|
|
}};
|
|
|
|
std::thread holder([&] {
|
|
EXPECT_TRUE(broker_->Drain(blocking_sink, 1).ok());
|
|
});
|
|
while (!dispatching.load(std::memory_order_acquire)) {
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
|
}
|
|
|
|
// Release on a timer, so a drain that does block behind the dispatch
|
|
// reports a slow elapsed time instead of deadlocking the test.
|
|
std::thread releaser([&] {
|
|
std::this_thread::sleep_for(std::chrono::seconds(2));
|
|
{
|
|
std::lock_guard<std::mutex> lock(gate_mutex);
|
|
released = true;
|
|
}
|
|
gate.notify_all();
|
|
});
|
|
|
|
CaptureProbe probe;
|
|
const auto started = std::chrono::steady_clock::now();
|
|
const Status idle = broker_->Drain({&probe, Capture}, 4);
|
|
const auto elapsed = std::chrono::steady_clock::now() - started;
|
|
|
|
releaser.join();
|
|
holder.join();
|
|
|
|
EXPECT_TRUE(idle.ok()) << idle.message();
|
|
EXPECT_TRUE(probe.requests.empty());
|
|
EXPECT_LT(std::chrono::duration_cast<std::chrono::milliseconds>(elapsed)
|
|
.count(),
|
|
250)
|
|
<< "idle drain blocked behind the in-flight dispatch";
|
|
}
|
|
|
|
// The lock-free idle path must not drop a request that arrives while a
|
|
// dispatch is running.
|
|
TEST_F(ExternalLaunchBrokerTest, DeliversARequestQueuedDuringADispatch) {
|
|
Start();
|
|
ASSERT_TRUE(broker_->QueueInitialRequest(Request(7101)).ok());
|
|
|
|
std::mutex gate_mutex;
|
|
std::condition_variable gate;
|
|
bool released = false;
|
|
std::atomic<bool> dispatching{false};
|
|
|
|
struct BlockingProbe {
|
|
std::mutex* gate_mutex;
|
|
std::condition_variable* gate;
|
|
bool* released;
|
|
std::atomic<bool>* dispatching;
|
|
} blocking{&gate_mutex, &gate, &released, &dispatching};
|
|
|
|
const ExternalLaunchSink blocking_sink{
|
|
&blocking,
|
|
[](void* context, const RobloxExperienceLaunchRequest&) -> Status {
|
|
auto* probe = static_cast<BlockingProbe*>(context);
|
|
probe->dispatching->store(true, std::memory_order_release);
|
|
std::unique_lock<std::mutex> lock(*probe->gate_mutex);
|
|
probe->gate->wait(lock, [probe] { return *probe->released; });
|
|
return Status::Ok();
|
|
}};
|
|
|
|
std::thread holder([&] {
|
|
EXPECT_TRUE(broker_->Drain(blocking_sink, 1).ok());
|
|
});
|
|
while (!dispatching.load(std::memory_order_acquire)) {
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
|
}
|
|
|
|
ASSERT_TRUE(broker_->QueueInitialRequest(Request(7102)).ok());
|
|
EXPECT_EQ(broker_->pending_launch_count(), 1u);
|
|
|
|
{
|
|
std::lock_guard<std::mutex> lock(gate_mutex);
|
|
released = true;
|
|
}
|
|
gate.notify_all();
|
|
holder.join();
|
|
|
|
CaptureProbe probe;
|
|
ASSERT_TRUE(broker_->Drain({&probe, Capture}, 4).ok());
|
|
ASSERT_EQ(probe.requests.size(), 1u);
|
|
EXPECT_EQ(probe.requests[0].place_id, 7102);
|
|
EXPECT_EQ(broker_->pending_launch_count(), 0u);
|
|
}
|
|
|
|
} // namespace
|
|
} // namespace runtime
|
|
} // namespace mocktail
|