Files
plezy/linux/runner/mpv/mpv_player_lifecycle_test.cc
edde746 bcd6fe9906 feat(linux): HDR video on a native Wayland plane
Video on Linux went through a Flutter texture: 8-bit sRGB, which cannot carry
HDR at all, and which forced a whole-window Flutter recomposite for every video
frame. This moves it onto a wl_subsurface stacked below the Flutter surface, with
mpv rendering into an EGL window surface on it through the libmpv render API. The
subsurface is desynchronized, so video and UI now present independently.

With the plane in place HDR follows: the surface is described to the compositor
through wp_color_manager_v1 as the source's own curve and gamut - PQ or HLG,
BT.2020 - carrying whatever HDR10 static metadata the stream actually declares.
The description and the buffer it describes land on the same commit, staged and
validated before mpv is switched, so a PQ frame is never presented labelled sRGB.
A five-second watchdog bounds the one wait a compositor could otherwise leave
hanging. A session that cannot host the plane - X11, or a compositor without
wl_subcompositor - fails initialize with VIDEO_PLANE_UNSUPPORTED naming the
reason: the texture path is gone, and refusing by name beats degrading to
something the user cannot see. An SDR output, a missing capability or an 8-bit
config keep the plane and simply leave it undescribed.

The output's colour state is trusted only when it has been earned. Every landed
property step records itself as it lands; a reset or sequence that cannot
finish downgrades its result to unknown and marks the applied-output cache
untrusted until a clean apply earns it back. A plane whose output state cannot
be named is quarantined - hidden, its description withdrawn - and the
quarantine is recorded state: an unrelated visibility change cannot put a
mislabelled plane back on screen, and only a commit that resolves to a nameable
outcome lifts it. A rect collapsing to zero detaches the buffer exactly as
hiding does, a refused setVideoRect drops the Dart-side sent-rect cache so the
next layout pass retries for free, and a refused tone-mapping pick tells the
user instead of dying in a log.

NVIDIA's Wayland EGL (through at least 610.xx) offers no 10-bit unorm window
configs, so the plane takes half-float as the tier between 10-bit unorm and
8-bit, declares the whole surface opaque so the compositor never reads the
alpha those configs carry, and states GL_RGBA16F rather than a 10-bit lie.
Whether the output is in HDR is read from luminance headroom above its own
reference white rather than from the preferred transfer function, which current
KWin no longer answers PQ for; the margin is half a stop, because KWin reports
an undimmed maximum over a software-dimmed SDR white. Validated on an RTX 4090
(driver 610.57.04) under KWin 6.7.4 with locked-exposure photographs.

Who tone-maps is a user choice. The default is the compositor: photographed on a
400-nit HDR output against a PQ chart it keeps 400 -> 1000 nits monotonic and
separated where the player leg flattens them, because the player path drives
mpv's legacy vo_gpu, whose own standalone output scores the same. The gap is the
renderer, not the wiring.

The decision itself - what the source carries, what the output supports, what to
tell mpv and what to tell the compositor - lives in hdr_metadata.h, free of
Wayland and GTK so its luminance validation can be tested without a display
server. Sending an incoherent luminance set is a protocol error that disconnects
the client, so the rules are worth a unit test.

The deb, rpm and pacman packages now declare wayland-client, wayland-egl and EGL:
the plane links them directly and bundle-libs.sh deliberately never bundles them,
since they are coupled to the running compositor and GPU driver.

lib/dev/harness_main.dart is a second entrypoint for measuring this on hardware -
it drives one clip with scripted mpv properties and reports the colour state mpv
actually settled on. Nothing imports it, so it is tree-shaken out of the app.

Verified on a Steam Deck against an external 400-nit HDR display: the compositor
reports PQ / BT.2020, the connector carries HDR_OUTPUT_METADATA, and against mpv
vo=gpu-next on the same frame the shipped build sits 4.90 counts away overall -
closer to the reference HDR player than to its own SDR fallback.
2026-08-10 08:48:13 +02:00

593 lines
22 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 <memory>
#include <mutex>
#include <stdexcept>
#include <thread>
#include <utility>
#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 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) == 2, "property event must contain the ID and value");
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");
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");
}
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::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;
}