746 lines
29 KiB
C++
746 lines
29 KiB
C++
#include <flutter_linux/flutter_linux.h>
|
|
#include <sys/types.h>
|
|
#include <sys/wait.h>
|
|
#include <unistd.h>
|
|
|
|
#include <atomic>
|
|
#include <cerrno>
|
|
#include <chrono>
|
|
#include <condition_variable>
|
|
#include <csignal>
|
|
#include <cstdlib>
|
|
#include <exception>
|
|
#include <iostream>
|
|
#include <limits>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <stdexcept>
|
|
#include <thread>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#include "mpv_player.h"
|
|
|
|
namespace mpv {
|
|
|
|
class MpvPlayerLifecycleTestPeer {
|
|
public:
|
|
static std::shared_ptr<MpvPlayer::CallbackContext> RetainContext(MpvPlayer& player) {
|
|
return player.callback_context_;
|
|
}
|
|
|
|
static void Wakeup(const std::shared_ptr<MpvPlayer::CallbackContext>& context) {
|
|
MpvPlayer::OnMpvWakeup(context.get());
|
|
}
|
|
|
|
static void RenderUpdate(const std::shared_ptr<MpvPlayer::CallbackContext>& context) {
|
|
MpvPlayer::OnMpvRenderUpdate(context.get());
|
|
}
|
|
|
|
static void WaitUntilDetached(const std::shared_ptr<MpvPlayer::CallbackContext>& context) {
|
|
context->WaitUntilDetached();
|
|
}
|
|
static void ScheduleRecovery(MpvPlayer& player) { player.ScheduleRecoverySource(); }
|
|
|
|
static void RegisterPendingPropertyWrite(MpvPlayer& player, MpvPlayer::StatusCallback callback) {
|
|
player.pending_requests_.RegisterStatus(std::move(callback));
|
|
}
|
|
|
|
static int PendingSourceCount(MpvPlayer& player) {
|
|
std::lock_guard<std::mutex> lock(player.source_mutex_);
|
|
return (player.wakeup_source_id_ != 0 ? 1 : 0) + (player.redraw_source_id_ != 0 ? 1 : 0) +
|
|
(player.recovery_source_id_ != 0 ? 1 : 0);
|
|
}
|
|
static FlValue* ConvertNode(MpvPlayer& player, mpv_node* node) { return player.NodeToFlValue(node); }
|
|
static FlValue* ConvertNodeWithBudget(
|
|
MpvPlayer& player, mpv_node* node, size_t remaining_entries, size_t remaining_bytes) {
|
|
plezy::mpv_common::NodeConversionBudget budget{remaining_entries, remaining_bytes};
|
|
return player.NodeToFlValue(node, &budget);
|
|
}
|
|
static void RegisterObservedNode(MpvPlayer& player, const std::string& name, int id) {
|
|
player.observed_properties_.Register(name, "node", id);
|
|
}
|
|
static void HandleEvent(MpvPlayer& player, mpv_event* event) { player.HandleMpvEvent(event); }
|
|
static void SendPlaybackRestart(MpvPlayer& player, const double* position_seconds) {
|
|
player.SendPlaybackRestartEvent(position_seconds);
|
|
}
|
|
|
|
static void HoldLease(
|
|
const std::shared_ptr<MpvPlayer::CallbackContext>& context, std::mutex& mutex, std::condition_variable& condition,
|
|
bool& entered, bool& release) {
|
|
auto lease = context->Acquire();
|
|
{
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
entered = static_cast<bool>(lease);
|
|
}
|
|
condition.notify_all();
|
|
|
|
std::unique_lock<std::mutex> lock(mutex);
|
|
condition.wait(lock, [&release]() { return release; });
|
|
}
|
|
};
|
|
|
|
namespace {
|
|
|
|
void Check(bool condition, const char* message) {
|
|
if (!condition) throw std::runtime_error(message);
|
|
}
|
|
|
|
void Drain(GMainContext* context) {
|
|
while (g_main_context_iteration(context, FALSE)) {
|
|
}
|
|
}
|
|
|
|
bool WriteByte(int descriptor, char value) {
|
|
for (;;) {
|
|
const ssize_t written = write(descriptor, &value, 1);
|
|
if (written == 1) return true;
|
|
if (written < 0 && errno == EINTR) continue;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
bool ReadByte(int descriptor, char expected) {
|
|
char value = '\0';
|
|
for (;;) {
|
|
const ssize_t received = read(descriptor, &value, 1);
|
|
if (received == 1) return value == expected;
|
|
if (received < 0 && errno == EINTR) continue;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
[[noreturn]] void ExitBlockedTeardownChild(int status) { _exit(status); }
|
|
|
|
int RunBlockedTeardownShutdownChild(int progress_read, int progress_write, int release_read) {
|
|
auto* const completed_handle = reinterpret_cast<mpv_handle*>(0x11);
|
|
auto* const blocked_render = reinterpret_cast<mpv_render_context*>(0x12);
|
|
auto const blocked_display = reinterpret_cast<EGLDisplay>(0x13);
|
|
auto const blocked_context = reinterpret_cast<EGLContext>(0x14);
|
|
|
|
NativeRenderTeardownOperations operations{
|
|
[](EGLDisplay, EGLContext) { return true; },
|
|
[](EGLDisplay) { return true; },
|
|
[](EGLDisplay, EGLContext) { return true; },
|
|
[progress_write, release_read, blocked_render](mpv_render_context* render) {
|
|
if (render != blocked_render || !WriteByte(progress_write, 'B')) ExitBlockedTeardownChild(121);
|
|
char release = '\0';
|
|
for (;;) {
|
|
const ssize_t received = read(release_read, &release, 1);
|
|
if (received == 1) break;
|
|
if (received < 0 && errno == EINTR) continue;
|
|
ExitBlockedTeardownChild(122);
|
|
}
|
|
},
|
|
[progress_write, completed_handle](mpv_handle* handle) {
|
|
if (handle != completed_handle || !WriteByte(progress_write, 'R')) ExitBlockedTeardownChild(123);
|
|
},
|
|
};
|
|
ConfigureNativeRenderTeardownQueueForTesting(std::move(operations));
|
|
|
|
NativeRenderTeardownBatch completed_batch;
|
|
completed_batch.handle = completed_handle;
|
|
EnqueueNativeRenderTeardownForTesting(std::move(completed_batch));
|
|
if (!ReadByte(progress_read, 'R')) return 124;
|
|
|
|
NativeRenderTeardownBatch blocked_batch;
|
|
blocked_batch.resources.push_back({blocked_render, blocked_display, blocked_context});
|
|
EnqueueNativeRenderTeardownForTesting(std::move(blocked_batch));
|
|
if (!ReadByte(progress_read, 'B')) return 125;
|
|
|
|
// Returning through std::exit below deliberately begins normal static
|
|
// shutdown while the queue worker remains blocked in free_render.
|
|
return 0;
|
|
}
|
|
|
|
void TestProcessShutdownDoesNotJoinBlockedNativeTeardown() {
|
|
int progress_pipe[2] = {-1, -1};
|
|
int release_pipe[2] = {-1, -1};
|
|
Check(pipe(progress_pipe) == 0, "could not create teardown progress barrier");
|
|
if (pipe(release_pipe) != 0) {
|
|
close(progress_pipe[0]);
|
|
close(progress_pipe[1]);
|
|
Check(false, "could not create teardown release barrier");
|
|
}
|
|
|
|
const pid_t child = fork();
|
|
if (child == 0) {
|
|
close(release_pipe[1]);
|
|
const int status = RunBlockedTeardownShutdownChild(progress_pipe[0], progress_pipe[1], release_pipe[0]);
|
|
std::exit(status);
|
|
}
|
|
if (child < 0) {
|
|
close(progress_pipe[0]);
|
|
close(progress_pipe[1]);
|
|
close(release_pipe[0]);
|
|
close(release_pipe[1]);
|
|
Check(false, "could not create teardown shutdown subprocess");
|
|
}
|
|
|
|
close(progress_pipe[0]);
|
|
close(progress_pipe[1]);
|
|
close(release_pipe[0]);
|
|
|
|
std::mutex wait_mutex;
|
|
std::condition_variable wait_condition;
|
|
bool wait_finished = false;
|
|
pid_t wait_result = -1;
|
|
int child_status = 0;
|
|
std::thread waiter([&]() {
|
|
pid_t result;
|
|
do {
|
|
result = waitpid(child, &child_status, 0);
|
|
} while (result < 0 && errno == EINTR);
|
|
{
|
|
std::lock_guard<std::mutex> lock(wait_mutex);
|
|
wait_result = result;
|
|
wait_finished = true;
|
|
}
|
|
wait_condition.notify_one();
|
|
});
|
|
|
|
bool exited_before_deadline = false;
|
|
{
|
|
std::unique_lock<std::mutex> lock(wait_mutex);
|
|
exited_before_deadline = wait_condition.wait_for(lock, std::chrono::seconds(2), [&]() { return wait_finished; });
|
|
}
|
|
if (!exited_before_deadline) kill(child, SIGKILL);
|
|
close(release_pipe[1]);
|
|
waiter.join();
|
|
|
|
Check(exited_before_deadline, "normal process shutdown joined a deliberately blocked native teardown");
|
|
Check(wait_result == child, "could not collect teardown shutdown subprocess");
|
|
Check(WIFEXITED(child_status), "teardown shutdown subprocess terminated abnormally");
|
|
Check(WEXITSTATUS(child_status) == 0, "teardown shutdown subprocess did not reach normal static shutdown");
|
|
}
|
|
|
|
void TestNodeConversionRejectsMalformedPayloads() {
|
|
MpvPlayer player;
|
|
|
|
mpv_node missing_list{};
|
|
missing_list.format = MPV_FORMAT_NODE_ARRAY;
|
|
missing_list.u.list = nullptr;
|
|
FlValue* result = MpvPlayerLifecycleTestPeer::ConvertNode(player, &missing_list);
|
|
Check(fl_value_get_type(result) == FL_VALUE_TYPE_NULL, "a node array without storage must decode as null");
|
|
fl_value_unref(result);
|
|
|
|
mpv_node value{};
|
|
value.format = MPV_FORMAT_INT64;
|
|
value.u.int64 = 1;
|
|
char* missing_key = nullptr;
|
|
mpv_node_list malformed_map{1, &value, &missing_key};
|
|
mpv_node map{};
|
|
map.format = MPV_FORMAT_NODE_MAP;
|
|
map.u.list = &malformed_map;
|
|
result = MpvPlayerLifecycleTestPeer::ConvertNode(player, &map);
|
|
Check(fl_value_get_type(result) == FL_VALUE_TYPE_NULL, "a node map with a null key must decode as null");
|
|
fl_value_unref(result);
|
|
|
|
char invalid_utf8[] = {'a', static_cast<char>(0xFF), 'b', '\0'};
|
|
mpv_node text{};
|
|
text.format = MPV_FORMAT_STRING;
|
|
text.u.string = invalid_utf8;
|
|
result = MpvPlayerLifecycleTestPeer::ConvertNode(player, &text);
|
|
Check(
|
|
std::string(fl_value_get_string(result)) ==
|
|
"a\xEF\xBF\xBD"
|
|
"b",
|
|
"invalid UTF-8 must be replaced before entering the Flutter codec");
|
|
fl_value_unref(result);
|
|
|
|
char oversized_text[] = "bounded";
|
|
text.u.string = oversized_text;
|
|
result =
|
|
MpvPlayerLifecycleTestPeer::ConvertNodeWithBudget(player, &text, /*remaining_entries=*/1, /*remaining_bytes=*/6);
|
|
Check(fl_value_get_type(result) == FL_VALUE_TYPE_NULL, "a node string beyond the byte budget must decode as null");
|
|
fl_value_unref(result);
|
|
}
|
|
|
|
void TestNullNodePropertyPayloadDecodesAsNull() {
|
|
MpvPlayer player;
|
|
MpvPlayerLifecycleTestPeer::RegisterObservedNode(player, "track-list", 42);
|
|
bool delivered = false;
|
|
player.SetEventCallback([&delivered](FlValue* event) {
|
|
Check(fl_value_get_type(event) == FL_VALUE_TYPE_LIST, "property event must remain a list");
|
|
Check(fl_value_get_length(event) == 3, "property event must contain the ID, value, and source ID");
|
|
Check(fl_value_get_int(fl_value_get_list_value(event, 0)) == 42, "property event ID changed");
|
|
Check(
|
|
fl_value_get_type(fl_value_get_list_value(event, 1)) == FL_VALUE_TYPE_NULL,
|
|
"a missing MPV node payload must decode as null");
|
|
Check(
|
|
fl_value_get_type(fl_value_get_list_value(event, 2)) == FL_VALUE_TYPE_NULL,
|
|
"property source must be null before START_FILE");
|
|
delivered = true;
|
|
});
|
|
|
|
mpv_event_property property{};
|
|
property.name = "track-list";
|
|
property.format = MPV_FORMAT_NODE;
|
|
property.data = nullptr;
|
|
mpv_event event{};
|
|
event.event_id = MPV_EVENT_PROPERTY_CHANGE;
|
|
event.data = &property;
|
|
MpvPlayerLifecycleTestPeer::HandleEvent(player, &event);
|
|
Check(delivered, "null node property event was not delivered");
|
|
}
|
|
|
|
FlValue* RequireMapField(FlValue* map, const char* key, const char* message) {
|
|
Check(map && fl_value_get_type(map) == FL_VALUE_TYPE_MAP, "event payload must be a map");
|
|
FlValue* value = fl_value_lookup_string(map, key);
|
|
Check(value != nullptr, message);
|
|
return value;
|
|
}
|
|
|
|
FlValue* RequireEventData(FlValue* event, const char* expected_name) {
|
|
Check(event && fl_value_get_type(event) == FL_VALUE_TYPE_MAP, "lifecycle event must be a map");
|
|
FlValue* name = RequireMapField(event, "name", "lifecycle event name is missing");
|
|
Check(
|
|
fl_value_get_type(name) == FL_VALUE_TYPE_STRING && std::string(fl_value_get_string(name)) == expected_name,
|
|
"lifecycle event name changed");
|
|
return RequireMapField(event, "data", "source-qualified lifecycle event data is missing");
|
|
}
|
|
|
|
void TestSourceQualifiedEventPayloads() {
|
|
MpvPlayer player;
|
|
MpvPlayerLifecycleTestPeer::RegisterObservedNode(player, "track-list", 42);
|
|
std::vector<FlValue*> events;
|
|
player.SetEventCallback([&events](FlValue* event) { events.push_back(fl_value_ref(event)); });
|
|
|
|
mpv_event_property property{};
|
|
property.name = "track-list";
|
|
property.format = MPV_FORMAT_NODE;
|
|
mpv_event property_event{};
|
|
property_event.event_id = MPV_EVENT_PROPERTY_CHANGE;
|
|
property_event.data = &property;
|
|
MpvPlayerLifecycleTestPeer::HandleEvent(player, &property_event);
|
|
|
|
constexpr int64_t kFirstSourceId = -5000000001LL;
|
|
mpv_event_start_file start{};
|
|
start.playlist_entry_id = kFirstSourceId;
|
|
mpv_event start_event{};
|
|
start_event.event_id = MPV_EVENT_START_FILE;
|
|
start_event.data = &start;
|
|
MpvPlayerLifecycleTestPeer::HandleEvent(player, &start_event);
|
|
MpvPlayerLifecycleTestPeer::HandleEvent(player, &property_event);
|
|
|
|
mpv_event file_loaded{};
|
|
file_loaded.event_id = MPV_EVENT_FILE_LOADED;
|
|
MpvPlayerLifecycleTestPeer::HandleEvent(player, &file_loaded);
|
|
|
|
mpv_event playback_restart{};
|
|
playback_restart.event_id = MPV_EVENT_PLAYBACK_RESTART;
|
|
MpvPlayerLifecycleTestPeer::HandleEvent(player, &playback_restart);
|
|
const double position_seconds = 17.25;
|
|
MpvPlayerLifecycleTestPeer::SendPlaybackRestart(player, &position_seconds);
|
|
const double invalid_position = std::numeric_limits<double>::infinity();
|
|
MpvPlayerLifecycleTestPeer::SendPlaybackRestart(player, &invalid_position);
|
|
|
|
constexpr int64_t kEndedSourceId = 6000000002LL;
|
|
mpv_event_end_file end{};
|
|
end.reason = MPV_END_FILE_REASON_ERROR;
|
|
end.error = MPV_ERROR_LOADING_FAILED;
|
|
end.playlist_entry_id = kEndedSourceId;
|
|
mpv_event end_event{};
|
|
end_event.event_id = MPV_EVENT_END_FILE;
|
|
end_event.data = &end;
|
|
MpvPlayerLifecycleTestPeer::HandleEvent(player, &end_event);
|
|
|
|
constexpr int64_t kNextSourceId = 7000000003LL;
|
|
start.playlist_entry_id = kNextSourceId;
|
|
MpvPlayerLifecycleTestPeer::HandleEvent(player, &start_event);
|
|
|
|
Check(events.size() == 9, "source-qualified event sequence changed");
|
|
|
|
Check(fl_value_get_type(events[0]) == FL_VALUE_TYPE_LIST, "property event must remain a list");
|
|
Check(fl_value_get_length(events[0]) == 3, "property event must contain ID, value, and source ID");
|
|
Check(fl_value_get_int(fl_value_get_list_value(events[0], 0)) == 42, "property event ID changed");
|
|
Check(
|
|
fl_value_get_type(fl_value_get_list_value(events[0], 1)) == FL_VALUE_TYPE_NULL,
|
|
"missing property data must remain null");
|
|
Check(
|
|
fl_value_get_type(fl_value_get_list_value(events[0], 2)) == FL_VALUE_TYPE_NULL,
|
|
"property source must be null before START_FILE");
|
|
|
|
FlValue* start_data = RequireEventData(events[1], "start-file");
|
|
Check(
|
|
fl_value_get_int(RequireMapField(start_data, "sourceId", "start-file source ID is missing")) == kFirstSourceId,
|
|
"start-file source ID lost signed 64-bit precision");
|
|
|
|
Check(fl_value_get_length(events[2]) == 3, "source-qualified property event must remain a triple");
|
|
Check(
|
|
fl_value_get_int(fl_value_get_list_value(events[2], 2)) == kFirstSourceId,
|
|
"property event did not retain the active source ID");
|
|
|
|
FlValue* loaded_data = RequireEventData(events[3], "file-loaded");
|
|
Check(
|
|
fl_value_get_int(RequireMapField(loaded_data, "sourceId", "file-loaded source ID is missing")) == kFirstSourceId,
|
|
"file-loaded source ID changed");
|
|
|
|
FlValue* restart_without_position = RequireEventData(events[4], "playback-restart");
|
|
Check(
|
|
fl_value_get_int(RequireMapField(
|
|
restart_without_position, "sourceId", "playback-restart source ID is missing")) == kFirstSourceId,
|
|
"playback-restart source ID changed");
|
|
Check(
|
|
fl_value_lookup_string(restart_without_position, "positionSeconds") == nullptr,
|
|
"unavailable playback position must not be manufactured");
|
|
|
|
FlValue* restart_data = RequireEventData(events[5], "playback-restart");
|
|
Check(
|
|
fl_value_get_int(RequireMapField(restart_data, "sourceId", "positioned playback-restart source ID is missing")) ==
|
|
kFirstSourceId,
|
|
"positioned playback-restart source ID changed");
|
|
FlValue* restart_position = RequireMapField(restart_data, "positionSeconds", "finite playback position is missing");
|
|
Check(
|
|
fl_value_get_type(restart_position) == FL_VALUE_TYPE_FLOAT &&
|
|
fl_value_get_float(restart_position) == position_seconds,
|
|
"playback-restart position changed");
|
|
|
|
FlValue* invalid_restart_data = RequireEventData(events[6], "playback-restart");
|
|
Check(
|
|
fl_value_lookup_string(invalid_restart_data, "positionSeconds") == nullptr,
|
|
"non-finite playback position must not enter the channel payload");
|
|
|
|
FlValue* end_data = RequireEventData(events[7], "end-file");
|
|
Check(
|
|
fl_value_get_int(RequireMapField(end_data, "sourceId", "end-file source ID is missing")) == kEndedSourceId,
|
|
"end-file must use its event-specific source ID");
|
|
Check(
|
|
fl_value_get_int(RequireMapField(end_data, "reason", "end-file reason is missing")) == MPV_END_FILE_REASON_ERROR,
|
|
"end-file reason changed");
|
|
Check(
|
|
fl_value_get_int(RequireMapField(end_data, "error", "end-file error is missing")) == MPV_ERROR_LOADING_FAILED,
|
|
"end-file error changed");
|
|
Check(
|
|
fl_value_get_type(RequireMapField(end_data, "message", "end-file message is missing")) == FL_VALUE_TYPE_STRING,
|
|
"end-file message changed type");
|
|
|
|
FlValue* next_start_data = RequireEventData(events[8], "start-file");
|
|
Check(
|
|
fl_value_get_int(RequireMapField(next_start_data, "sourceId", "replacement source ID is missing")) ==
|
|
kNextSourceId,
|
|
"replacement source ID changed");
|
|
Check(
|
|
fl_value_get_int(fl_value_get_list_value(events[2], 2)) == kFirstSourceId,
|
|
"later START_FILE relabeled an already-dispatched property");
|
|
|
|
player.SetEventCallback(nullptr);
|
|
for (FlValue* event : events) fl_value_unref(event);
|
|
}
|
|
|
|
void TestUnavailableCommandFails() {
|
|
MpvPlayer player;
|
|
int callback_count = 0;
|
|
int status = MPV_ERROR_SUCCESS;
|
|
|
|
player.CommandAsync({"stop"}, [&](int error) {
|
|
++callback_count;
|
|
status = error;
|
|
});
|
|
|
|
Check(callback_count == 1, "a command without an mpv handle must complete exactly once");
|
|
Check(status == MPV_ERROR_UNINITIALIZED, "a command without an mpv handle must fail as uninitialized");
|
|
}
|
|
|
|
void TestUnavailablePropertyWriteFails() {
|
|
MpvPlayer player;
|
|
int callback_count = 0;
|
|
int status = MPV_ERROR_SUCCESS;
|
|
|
|
player.SetPropertyAsync("pause", "yes", [&](int error) {
|
|
++callback_count;
|
|
status = error;
|
|
});
|
|
|
|
Check(callback_count == 1, "a property write without an mpv handle must complete exactly once");
|
|
Check(status == MPV_ERROR_UNINITIALIZED, "a property write without an mpv handle must fail as uninitialized");
|
|
}
|
|
|
|
void TestPendingPropertyWriteFailsOnDispose() {
|
|
MpvPlayer player;
|
|
int callback_count = 0;
|
|
int status = MPV_ERROR_SUCCESS;
|
|
MpvPlayerLifecycleTestPeer::RegisterPendingPropertyWrite(player, [&](int error) {
|
|
++callback_count;
|
|
status = error;
|
|
});
|
|
|
|
player.Dispose();
|
|
Check(callback_count == 1, "dispose must complete a pending property write exactly once");
|
|
Check(status == MPV_ERROR_UNINITIALIZED, "dispose must cancel a pending property write as uninitialized");
|
|
|
|
player.Dispose();
|
|
Check(callback_count == 1, "repeated dispose must not complete a property write twice");
|
|
}
|
|
|
|
void TestQueuedSourcesAreRetired(GMainContext* context) {
|
|
int redraws = 0;
|
|
auto player = std::make_unique<MpvPlayer>();
|
|
auto callback_context = MpvPlayerLifecycleTestPeer::RetainContext(*player);
|
|
player->SetRedrawCallback([&redraws]() { ++redraws; });
|
|
|
|
MpvPlayerLifecycleTestPeer::Wakeup(callback_context);
|
|
MpvPlayerLifecycleTestPeer::RenderUpdate(callback_context);
|
|
MpvPlayerLifecycleTestPeer::ScheduleRecovery(*player);
|
|
Check(MpvPlayerLifecycleTestPeer::PendingSourceCount(*player) == 3, "all player sources must be tracked");
|
|
|
|
player->Dispose();
|
|
Check(MpvPlayerLifecycleTestPeer::PendingSourceCount(*player) == 0, "dispose must retire every tracked source");
|
|
player.reset();
|
|
|
|
MpvPlayerLifecycleTestPeer::Wakeup(callback_context);
|
|
MpvPlayerLifecycleTestPeer::RenderUpdate(callback_context);
|
|
Drain(context);
|
|
Check(redraws == 0, "detached callbacks must not publish redraws");
|
|
}
|
|
|
|
void TestNativeLeaseBlocksDispose() {
|
|
auto player = std::make_unique<MpvPlayer>();
|
|
auto callback_context = MpvPlayerLifecycleTestPeer::RetainContext(*player);
|
|
std::mutex mutex;
|
|
std::condition_variable condition;
|
|
bool entered = false;
|
|
bool release = false;
|
|
|
|
std::thread holder(
|
|
[&]() { MpvPlayerLifecycleTestPeer::HoldLease(callback_context, mutex, condition, entered, release); });
|
|
{
|
|
std::unique_lock<std::mutex> lock(mutex);
|
|
condition.wait(lock, [&entered]() { return entered; });
|
|
}
|
|
|
|
std::atomic<bool> disposed{false};
|
|
std::thread disposer([&]() {
|
|
player->Dispose();
|
|
disposed = true;
|
|
});
|
|
MpvPlayerLifecycleTestPeer::WaitUntilDetached(callback_context);
|
|
Check(!disposed.load(), "dispose returned while a native callback lease was active");
|
|
|
|
{
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
release = true;
|
|
}
|
|
condition.notify_all();
|
|
holder.join();
|
|
disposer.join();
|
|
Check(disposed.load(), "dispose did not finish after the native callback lease was released");
|
|
|
|
player.reset();
|
|
MpvPlayerLifecycleTestPeer::Wakeup(callback_context);
|
|
MpvPlayerLifecycleTestPeer::RenderUpdate(callback_context);
|
|
}
|
|
|
|
void TestWakeupAndRedrawCoalesce(GMainContext* context) {
|
|
int redraws = 0;
|
|
MpvPlayer player;
|
|
auto callback_context = MpvPlayerLifecycleTestPeer::RetainContext(player);
|
|
player.SetRedrawCallback([&redraws]() { ++redraws; });
|
|
|
|
for (int i = 0; i < 10; ++i) {
|
|
MpvPlayerLifecycleTestPeer::Wakeup(callback_context);
|
|
MpvPlayerLifecycleTestPeer::RenderUpdate(callback_context);
|
|
}
|
|
Check(MpvPlayerLifecycleTestPeer::PendingSourceCount(player) == 2, "wakeup and redraw sources must coalesce");
|
|
Drain(context);
|
|
Check(redraws == 1, "coalesced redraw was not delivered exactly once");
|
|
Check(MpvPlayerLifecycleTestPeer::PendingSourceCount(player) == 0, "dispatched source IDs must be cleared");
|
|
|
|
player.ClearRedrawFlag();
|
|
MpvPlayerLifecycleTestPeer::RenderUpdate(callback_context);
|
|
Drain(context);
|
|
Check(redraws == 2, "a redraw after dispatch must still be delivered");
|
|
}
|
|
|
|
void TestRapidReplacementCannotReceiveOldCallbacks(GMainContext* context) {
|
|
for (int iteration = 0; iteration < 100; ++iteration) {
|
|
int old_redraws = 0;
|
|
int replacement_redraws = 0;
|
|
|
|
auto old_player = std::make_unique<MpvPlayer>();
|
|
auto old_context = MpvPlayerLifecycleTestPeer::RetainContext(*old_player);
|
|
old_player->SetRedrawCallback([&old_redraws]() { ++old_redraws; });
|
|
MpvPlayerLifecycleTestPeer::Wakeup(old_context);
|
|
MpvPlayerLifecycleTestPeer::RenderUpdate(old_context);
|
|
old_player->Dispose();
|
|
old_player.reset();
|
|
|
|
auto replacement = std::make_unique<MpvPlayer>();
|
|
auto replacement_context = MpvPlayerLifecycleTestPeer::RetainContext(*replacement);
|
|
replacement->SetRedrawCallback([&replacement_redraws]() { ++replacement_redraws; });
|
|
|
|
// Simulate both an entered-old callback resuming and fresh replacement work.
|
|
MpvPlayerLifecycleTestPeer::Wakeup(old_context);
|
|
MpvPlayerLifecycleTestPeer::RenderUpdate(old_context);
|
|
MpvPlayerLifecycleTestPeer::Wakeup(replacement_context);
|
|
MpvPlayerLifecycleTestPeer::RenderUpdate(replacement_context);
|
|
Drain(context);
|
|
|
|
Check(old_redraws == 0, "an old redraw callback ran after replacement");
|
|
Check(replacement_redraws == 1, "old callback state suppressed or duplicated a replacement redraw");
|
|
replacement->Dispose();
|
|
}
|
|
}
|
|
|
|
void TestRenderTeardownRetainsOwnershipUntilContextIsCurrent() {
|
|
NativeRenderTeardownBatch batch;
|
|
auto* render = reinterpret_cast<mpv_render_context*>(1);
|
|
auto* handle = reinterpret_cast<mpv_handle*>(2);
|
|
auto display = reinterpret_cast<EGLDisplay>(3);
|
|
auto context = reinterpret_cast<EGLContext>(4);
|
|
batch.resources.push_back({render, display, context});
|
|
batch.handle = handle;
|
|
|
|
bool allow_make_current = false;
|
|
bool allow_release = true;
|
|
int make_current_calls = 0;
|
|
int release_calls = 0;
|
|
int free_calls = 0;
|
|
int destroy_calls = 0;
|
|
int terminate_calls = 0;
|
|
NativeRenderTeardownOperations operations{
|
|
[&](EGLDisplay actual_display, EGLContext actual_context) {
|
|
Check(actual_display == display && actual_context == context, "teardown must bind the retained EGL context");
|
|
++make_current_calls;
|
|
return allow_make_current;
|
|
},
|
|
[&](EGLDisplay actual_display) {
|
|
Check(actual_display == display, "teardown must release the retained EGL display");
|
|
++release_calls;
|
|
return allow_release;
|
|
},
|
|
[&](EGLDisplay actual_display, EGLContext actual_context) {
|
|
Check(actual_display == display && actual_context == context, "teardown destroyed the wrong EGL context");
|
|
++destroy_calls;
|
|
return true;
|
|
},
|
|
[&](mpv_render_context* actual_render) {
|
|
Check(actual_render == render, "teardown freed the wrong render context");
|
|
++free_calls;
|
|
},
|
|
[&](mpv_handle* actual_handle) {
|
|
Check(actual_handle == handle, "teardown terminated the wrong mpv handle");
|
|
++terminate_calls;
|
|
},
|
|
};
|
|
|
|
Check(!TryReleaseNativeRenderTeardown(batch, operations), "a failed EGL bind must retain the native teardown batch");
|
|
Check(make_current_calls == 1, "teardown must attempt to bind the required EGL context");
|
|
Check(
|
|
free_calls == 0 && release_calls == 0 && destroy_calls == 0 && terminate_calls == 0,
|
|
"a failed EGL bind must not free, destroy, or terminate dependent native objects");
|
|
Check(
|
|
batch.resources.size() == 1 && batch.resources.front().render == render && batch.handle == handle,
|
|
"a failed EGL bind must preserve complete ownership for retry");
|
|
|
|
allow_make_current = true;
|
|
Check(TryReleaseNativeRenderTeardown(batch, operations), "a later valid EGL bind must complete retained teardown");
|
|
Check(batch.resources.empty() && batch.handle == nullptr, "successful retry must consume the teardown batch");
|
|
Check(
|
|
free_calls == 1 && release_calls == 1 && destroy_calls == 1 && terminate_calls == 1,
|
|
"successful retry must release the render, EGL context, and then the shared handle exactly once");
|
|
}
|
|
|
|
void TestRenderTeardownDoesNotDestroyAStillCurrentContext() {
|
|
NativeRenderTeardownBatch batch;
|
|
auto* render = reinterpret_cast<mpv_render_context*>(5);
|
|
auto* handle = reinterpret_cast<mpv_handle*>(6);
|
|
auto display = reinterpret_cast<EGLDisplay>(7);
|
|
auto context = reinterpret_cast<EGLContext>(8);
|
|
batch.resources.push_back({render, display, context});
|
|
batch.handle = handle;
|
|
|
|
bool allow_release = false;
|
|
int free_calls = 0;
|
|
int destroy_calls = 0;
|
|
int terminate_calls = 0;
|
|
NativeRenderTeardownOperations operations{
|
|
[](EGLDisplay, EGLContext) { return true; },
|
|
[&](EGLDisplay) { return allow_release; },
|
|
[&](EGLDisplay, EGLContext) {
|
|
++destroy_calls;
|
|
return true;
|
|
},
|
|
[&](mpv_render_context*) { ++free_calls; },
|
|
[&](mpv_handle*) { ++terminate_calls; },
|
|
};
|
|
|
|
Check(!TryReleaseNativeRenderTeardown(batch, operations), "a context that cannot be released must remain queued");
|
|
Check(free_calls == 1, "the render context may be freed only after its EGL context became current");
|
|
Check(
|
|
destroy_calls == 0 && terminate_calls == 0 && batch.resources.front().render == nullptr,
|
|
"failed EGL release must retain the context and handle without double-freeing the render");
|
|
|
|
allow_release = true;
|
|
Check(TryReleaseNativeRenderTeardown(batch, operations), "a later EGL release must finish teardown");
|
|
Check(
|
|
free_calls == 1 && destroy_calls == 1 && terminate_calls == 1,
|
|
"retry must not repeat render-context destruction");
|
|
}
|
|
|
|
// A batch that cannot bind its context keeps every resource for the next
|
|
// attempt, and a later attempt consumes each exactly once. The teardown queue
|
|
// retries on its own thread, so "preserved, then consumed once" is the contract
|
|
// that stops a retry either leaking a context or destroying one twice.
|
|
void TestFailedTeardownIsRetriedAndConsumedExactlyOnce() {
|
|
NativeRenderTeardownBatch batch;
|
|
batch.resources.push_back(
|
|
{reinterpret_cast<mpv_render_context*>(9), reinterpret_cast<EGLDisplay>(10), reinterpret_cast<EGLContext>(11)});
|
|
bool allow_make_current = false;
|
|
int free_calls = 0;
|
|
int destroy_calls = 0;
|
|
NativeRenderTeardownOperations operations{
|
|
[&](EGLDisplay, EGLContext) { return allow_make_current; },
|
|
[](EGLDisplay) { return true; },
|
|
[&](EGLDisplay, EGLContext) {
|
|
++destroy_calls;
|
|
return true;
|
|
},
|
|
[&](mpv_render_context*) { ++free_calls; },
|
|
[](mpv_handle*) { Check(false, "retained initialization cleanup must not terminate the shared core"); },
|
|
};
|
|
|
|
Check(!TryReleaseNativeRenderTeardown(batch, operations), "a batch that cannot bind must not report completion");
|
|
Check(batch.resources.size() == 1, "failed teardown must preserve ownership for another GL-thread retry");
|
|
|
|
allow_make_current = true;
|
|
Check(TryReleaseNativeRenderTeardown(batch, operations), "teardown completes once the context can be bound");
|
|
Check(batch.resources.empty(), "successful teardown must consume the render context");
|
|
Check(free_calls == 1 && destroy_calls == 1, "teardown must release each native object exactly once");
|
|
}
|
|
|
|
} // namespace
|
|
} // namespace mpv
|
|
|
|
int main() {
|
|
GMainContext* context = g_main_context_new();
|
|
g_main_context_push_thread_default(context);
|
|
|
|
try {
|
|
mpv::TestProcessShutdownDoesNotJoinBlockedNativeTeardown();
|
|
mpv::TestUnavailablePropertyWriteFails();
|
|
mpv::TestNodeConversionRejectsMalformedPayloads();
|
|
mpv::TestUnavailableCommandFails();
|
|
mpv::TestPendingPropertyWriteFailsOnDispose();
|
|
mpv::TestQueuedSourcesAreRetired(context);
|
|
mpv::TestNativeLeaseBlocksDispose();
|
|
mpv::TestWakeupAndRedrawCoalesce(context);
|
|
mpv::TestRapidReplacementCannotReceiveOldCallbacks(context);
|
|
mpv::TestRenderTeardownRetainsOwnershipUntilContextIsCurrent();
|
|
mpv::TestRenderTeardownDoesNotDestroyAStillCurrentContext();
|
|
mpv::TestNullNodePropertyPayloadDecodesAsNull();
|
|
mpv::TestSourceQualifiedEventPayloads();
|
|
mpv::TestFailedTeardownIsRetriedAndConsumedExactlyOnce();
|
|
} catch (const std::exception& error) {
|
|
g_main_context_pop_thread_default(context);
|
|
g_main_context_unref(context);
|
|
std::cerr << "mpv_player_lifecycle_test: " << error.what() << '\n';
|
|
return 1;
|
|
}
|
|
|
|
g_main_context_pop_thread_default(context);
|
|
g_main_context_unref(context);
|
|
std::cout << "mpv_player_lifecycle_test: PASS\n";
|
|
return 0;
|
|
}
|