fix: move linux mpv polling off main thread

This commit is contained in:
4gray committed 2026-06-13 11:41:17 +02:00
1 parent 15cce63978
commit e8d1cf2108
3 files changed
+180 -35

No files matched your search

@@ -146,6 +146,9 @@ struct Session {
std::atomic<uint64_t> gNextSessionId{1};
std::atomic<uint64_t> gNextAsyncRequestId{1};
#ifdef __linux__
std::atomic<uint64_t> gNextLinuxIpcSocketId{1};
#endif
std::mutex gSessionsMutex;
std::unordered_map<std::string, std::shared_ptr<Session>> gSessions;
@@ -339,6 +342,11 @@ Bounds readBounds(const Napi::Object& object)
}
#ifdef __linux__
struct LinuxMpvProcess {
pid_t processId = -1;
std::string ipcSocketPath;
};
bool isExecutableAvailable(const char* executableName)
{
const char* pathValue = std::getenv("PATH");
@@ -403,7 +411,7 @@ std::vector<std::string> buildLinuxMpvEnvironment()
void updateLinuxProcessState(const std::shared_ptr<Session>& session)
{
if (!session) {
if (!session || !session->running.load()) {
return;
}
@@ -421,8 +429,14 @@ void updateLinuxProcessState(const std::shared_ptr<Session>& session)
if (result != processId) {
return;
}
if (!session->running.load()) {
return;
}
std::lock_guard<std::mutex> lock(session->mutex);
if (session->mpvProcessId != processId) {
return;
}
session->mpvProcessId = -1;
session->snapshot.status =
WIFEXITED(status) && WEXITSTATUS(status) == 0
@@ -433,46 +447,65 @@ void updateLinuxProcessState(const std::shared_ptr<Session>& session)
}
}
void terminateLinuxMpvProcess(const std::shared_ptr<Session>& session)
void unlinkLinuxIpcSocket(const std::string& ipcSocketPath)
{
if (!ipcSocketPath.empty()) {
unlink(ipcSocketPath.c_str());
}
}
LinuxMpvProcess takeLinuxMpvProcess(const std::shared_ptr<Session>& session)
{
LinuxMpvProcess process;
if (!session) {
return;
return process;
}
pid_t processId = -1;
std::string ipcSocketPath;
{
std::lock_guard<std::mutex> lock(session->mutex);
processId = session->mpvProcessId;
process.processId = session->mpvProcessId;
session->mpvProcessId = -1;
ipcSocketPath = session->mpvIpcSocketPath;
process.ipcSocketPath = session->mpvIpcSocketPath;
session->mpvIpcSocketPath.clear();
}
if (processId <= 0) {
if (!ipcSocketPath.empty()) {
unlink(ipcSocketPath.c_str());
}
return process;
}
void waitForLinuxMpvProcessExit(LinuxMpvProcess process)
{
if (process.processId <= 0) {
unlinkLinuxIpcSocket(process.ipcSocketPath);
return;
}
kill(processId, SIGTERM);
for (int attempt = 0; attempt < 10; attempt += 1) {
int status = 0;
const pid_t result = waitpid(processId, &status, WNOHANG);
if (result == processId || result == -1) {
if (!ipcSocketPath.empty()) {
unlink(ipcSocketPath.c_str());
}
const pid_t result = waitpid(process.processId, &status, WNOHANG);
if (result == process.processId || result == -1) {
unlinkLinuxIpcSocket(process.ipcSocketPath);
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
kill(processId, SIGKILL);
waitpid(processId, nullptr, 0);
if (!ipcSocketPath.empty()) {
unlink(ipcSocketPath.c_str());
kill(process.processId, SIGKILL);
waitpid(process.processId, nullptr, 0);
unlinkLinuxIpcSocket(process.ipcSocketPath);
}
void terminateLinuxMpvProcessAsync(const std::shared_ptr<Session>& session)
{
auto process = takeLinuxMpvProcess(session);
if (process.processId <= 0) {
unlinkLinuxIpcSocket(process.ipcSocketPath);
return;
}
kill(process.processId, SIGTERM);
std::thread([process = std::move(process)]() mutable {
waitForLinuxMpvProcessExit(std::move(process));
}).detach();
}
void appendLinuxMpvOption(
@@ -574,7 +607,8 @@ std::string buildLinuxIpcSocketPath(const std::shared_ptr<Session>& session)
return "/tmp/iptvnator-embedded-mpv-" +
std::to_string(static_cast<long>(getpid())) +
"-" + safeSessionId + ".sock";
"-" + safeSessionId +
"-" + std::to_string(gNextLinuxIpcSocketId.fetch_add(1)) + ".sock";
}
bool writeAll(int fileDescriptor, const std::string& payload)
@@ -795,6 +829,9 @@ bool sendLinuxMpvCommand(
void refreshLinuxMpvSnapshot(const std::shared_ptr<Session>& session)
{
updateLinuxProcessState(session);
if (!session || !session->running.load()) {
return;
}
std::string socketPath;
pid_t processId = -1;
@@ -814,6 +851,13 @@ void refreshLinuxMpvSnapshot(const std::shared_ptr<Session>& session)
const auto path = queryLinuxMpvString(socketPath, "path");
std::lock_guard<std::mutex> lock(session->mutex);
if (
!session->running.load() ||
session->mpvProcessId != processId ||
session->mpvIpcSocketPath != socketPath
) {
return;
}
if (position) {
session->snapshot.positionSeconds = std::max(0.0, *position);
}
@@ -833,6 +877,37 @@ void refreshLinuxMpvSnapshot(const std::shared_ptr<Session>& session)
}
}
void runLinuxProcessPollLoop(std::shared_ptr<Session> session)
{
while (session && session->running.load()) {
refreshLinuxMpvSnapshot(session);
for (int tick = 0; tick < 10 && session->running.load(); tick += 1) {
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
}
}
bool startLinuxProcessPolling(const std::shared_ptr<Session>& session)
{
if (!session) {
return false;
}
bool expected = false;
if (!session->running.compare_exchange_strong(expected, true)) {
return true;
}
try {
session->eventThread = std::thread(runLinuxProcessPollLoop, session);
} catch (...) {
session->running.store(false);
return false;
}
return true;
}
pid_t spawnLinuxMpvProcess(
const std::vector<std::string>& arguments,
const std::vector<std::string>& environment)
@@ -884,7 +959,7 @@ void loadLinuxProcessPlayback(
const std::string& referer,
double startTime)
{
terminateLinuxMpvProcess(session);
terminateLinuxMpvProcessAsync(session);
const auto ipcSocketPath = buildLinuxIpcSocketPath(session);
unlink(ipcSocketPath.c_str());
const auto arguments = buildLinuxMpvArguments(
@@ -924,6 +999,13 @@ void loadLinuxProcessPlayback(
session->snapshot.recordingStartedAt.clear();
session->snapshot.recordingError.clear();
}
if (!startLinuxProcessPolling(session)) {
terminateLinuxMpvProcessAsync(session);
throw Napi::Error::New(
env,
"Failed to start embedded MPV snapshot polling."
);
}
}
#endif
@@ -1311,7 +1393,19 @@ void destroySession(const std::shared_ptr<Session>& session)
}
#ifdef __linux__
terminateLinuxMpvProcess(session);
session->running.store(false);
terminateLinuxMpvProcessAsync(session);
if (session->eventThread.joinable()) {
session->eventThread.detach();
}
session->host.destroy();
{
std::lock_guard<std::mutex> lock(session->mutex);
session->snapshot.status = SessionStatus::Closed;
session->snapshot.recordingActive = false;
session->snapshot.recordingStartedAt.clear();
}
return;
#endif
session->running.store(false);
if (session->handle) {
@@ -2005,10 +2099,6 @@ Napi::Value GetSessionSnapshot(const Napi::CallbackInfo& info)
return env.Null();
}
#ifdef __linux__
refreshLinuxMpvSnapshot(session);
#endif
SessionSnapshot snapshot;
{
std::lock_guard<std::mutex> lock(session->mutex);
@@ -41,22 +41,30 @@ describe('Embedded MPV native source recording invariants', () => {
);
function functionBody(name: string): string {
const start = nativeSource.indexOf(`Napi::Value ${name}(`);
return sourceFunctionBody(nativeSource, `Napi::Value ${name}(`, name);
}
function sourceFunctionBody(
source: string,
signature: string,
name: string
): string {
const start = source.indexOf(signature);
expect(start).toBeGreaterThanOrEqual(0);
const bodyStart = nativeSource.indexOf('{', start);
const bodyStart = source.indexOf('{', start);
expect(bodyStart).toBeGreaterThanOrEqual(0);
let depth = 0;
for (let index = bodyStart; index < nativeSource.length; index += 1) {
if (nativeSource[index] === '{') {
for (let index = bodyStart; index < source.length; index += 1) {
if (source[index] === '{') {
depth += 1;
}
if (nativeSource[index] === '}') {
if (source[index] === '}') {
depth -= 1;
}
if (depth === 0) {
return nativeSource.slice(bodyStart, index + 1);
return source.slice(bodyStart, index + 1);
}
}
@@ -346,6 +354,53 @@ describe('Embedded MPV native source recording invariants', () => {
);
});
it('keeps Linux MPV snapshot IPC off the NAPI snapshot read path', () => {
const getSnapshotBody = sourceFunctionBody(
widCommonSource,
'Napi::Value GetSessionSnapshot(',
'GetSessionSnapshot'
);
expect(getSnapshotBody).not.toContain('refreshLinuxMpvSnapshot');
expect(widCommonSource).toContain('void runLinuxProcessPollLoop');
expect(widCommonSource).toContain('refreshLinuxMpvSnapshot(session);');
expect(widCommonSource).toContain('startLinuxProcessPolling(session)');
expect(widCommonSource).toContain(
'session->eventThread = std::thread(runLinuxProcessPollLoop, session);'
);
});
it('terminates Linux MPV processes away from the NAPI teardown path', () => {
const destroyBody = sourceFunctionBody(
widCommonSource,
'void destroySession(',
'destroySession'
);
expect(destroyBody).toContain(
'terminateLinuxMpvProcessAsync(session);'
);
expect(destroyBody).toContain('session->eventThread.detach();');
expect(destroyBody).not.toContain('std::this_thread::sleep_for');
expect(destroyBody).not.toContain('SIGKILL');
const asyncTerminationBody = sourceFunctionBody(
widCommonSource,
'void terminateLinuxMpvProcessAsync(',
'terminateLinuxMpvProcessAsync'
);
expect(asyncTerminationBody).toContain(
'kill(process.processId, SIGTERM);'
);
expect(asyncTerminationBody).toContain('std::thread(');
expect(asyncTerminationBody).toContain('waitForLinuxMpvProcessExit');
});
it('uses generation-unique Linux MPV IPC socket paths', () => {
expect(widCommonSource).toContain('gNextLinuxIpcSocketId');
expect(widCommonSource).toContain(
'std::to_string(gNextLinuxIpcSocketId.fetch_add(1))'
);
});
it('keeps dynamic libmpv symbol declarations compatible with distro headers', () => {
expect(widCommonSource).not.toContain('MPV_CPLUGIN_DYNAMIC_SYM');
expect(widCommonSource).toContain('#ifdef IPTVNATOR_DYNAMIC_LIBMPV');
+1 -1
View File
@@ -28,7 +28,7 @@ The build directory contains files such as `Makefile`, `binding.Makefile`, `conf
The embedded player renders MPV frames into an app-owned native video surface. macOS uses the libmpv render API in an `NSOpenGLView` because the mpv `wid` path produced a black video surface inside Electron. Windows loads `libmpv` through the native Node addon and uses mpv's `wid` option against an IPTVnator-owned child `HWND`. Linux creates an IPTVnator-owned X11/Xwayland child `Window` and starts an out-of-process `mpv --wid=<window>` instance for that child window.
On Linux, `embedded_mpv.node` must not link directly to `libmpv` or load libmpv in-process. Electron loads its own `libffmpeg` and Chromium graphics stack; in-process libmpv can resolve FFmpeg/GL symbols against incompatible Electron symbols, while isolated dynamic-loader namespaces introduce thread/runtime ownership problems. The Linux addon therefore owns only the X11 child-window embedding, process lifecycle, and a private MPV JSON IPC socket. It starts `mpv --wid=<window> --input-ipc-server=<socket>`, polls `time-pos`, `duration`, `volume`, and `pause`, and forwards pause/seek/volume/audio-track commands through that socket. A healthy Linux build lists X11/Xext as addon dependencies, but `ldd apps/electron-backend/native/build/Release/embedded_mpv.node` must not list `libmpv`. Runtime support also requires an `mpv` executable on `PATH`.
On Linux, `embedded_mpv.node` must not link directly to `libmpv` or load libmpv in-process. Electron loads its own `libffmpeg` and Chromium graphics stack; in-process libmpv can resolve FFmpeg/GL symbols against incompatible Electron symbols, while isolated dynamic-loader namespaces introduce thread/runtime ownership problems. The Linux addon therefore owns only the X11 child-window embedding, process lifecycle, and a private MPV JSON IPC socket. It starts `mpv --wid=<window> --input-ipc-server=<socket>`, polls `time-pos`, `duration`, `volume`, and `pause`, and forwards pause/seek/volume/audio-track commands through that socket. The Linux MPV JSON IPC polling runs on an addon-owned background thread; `getSessionSnapshot()` returns the last cached snapshot and must not perform socket round trips on Electron's main thread. Linux MPV process teardown sends `SIGTERM` on the caller path, then waits and escalates to `SIGKILL` on a detached cleanup thread. A healthy Linux build lists X11/Xext as addon dependencies, but `ldd apps/electron-backend/native/build/Release/embedded_mpv.node` must not list `libmpv`. Runtime support also requires an `mpv` executable on `PATH`.
Linux native Wayland embedding is not implemented. When Electron is started on Xwayland, the Linux backend also starts the child MPV process with `WAYLAND_DISPLAY` removed, `XDG_SESSION_TYPE=x11`, `--vo=gpu,x11`, and `--gpu-context=x11egl`. This prevents MPV from choosing a Wayland VO in a Wayland desktop session, which would ignore the X11 `--wid` target and open a separate top-level MPV window.