* @copyright 2025-2026 Vateron Media * @link https://github.com/Vateron-Media/XC_VM * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html */ class ProcessManager { /** @var array Static cache for /proc existence checks */ protected static $procCache = []; /** @var float Cache TTL in seconds */ protected static $cacheTtl = 1.0; // ─────────────────────────────────────────────────────────── // Process Checking // ─────────────────────────────────────────────────────────── /** * Check if a process is running via /proc filesystem * * @param int|null $pid Process ID (null/0 -> not running) * @param string|null $exe Expected executable name (e.g., 'ffmpeg', 'php') * @return bool */ public static function isRunning(?int $pid, ?string $exe = null) { $pid = (int) $pid; if ($pid <= 0) { return false; } if (!self::procExists($pid)) { return false; } // If no exe filter — just check /proc exists if ($exe === null) { return true; } // Check executable matches if (!is_readable('/proc/' . $pid . '/exe')) { return false; } $actualExe = @basename(@readlink('/proc/' . $pid . '/exe')); return strpos($actualExe, basename($exe)) === 0; } /** * Check if a named process is running (e.g., XC_VM[123]) * * Reads /proc/PID/cmdline and matches against "NAME[ID]" pattern. * * @param int|null $pid Process ID (null/0 -> not running) * @param string $processName Process name prefix (e.g., 'XC_VM', 'Thumbnail', 'TVArchive') * @param int|string $identifier Stream/task ID * @param string|null $exe Expected executable (default: PHP_BIN) * @return bool */ public static function isNamedProcessRunning(?int $pid, string $processName, int|string $identifier, ?string $exe = null) { $pid = (int) $pid; if ($pid <= 0) { return false; } if ($exe === null && defined('PHP_BIN')) { $exe = PHP_BIN; } clearstatcache(true); if (!self::procExists($pid)) { return false; } if ($exe && !is_readable('/proc/' . $pid . '/exe')) { return false; } if ($exe) { $actualExe = @basename(@readlink('/proc/' . $pid . '/exe')); if (strpos($actualExe, basename($exe)) !== 0) { return false; } } $cmdline = trim(@file_get_contents('/proc/' . $pid . '/cmdline')); $expected = $processName . '[' . $identifier . ']'; return $cmdline === $expected; } /** * Check if a stream (ffmpeg/php) process is running * * Specialized check for streaming processes that match * either ffmpeg with specific stream output files, or PHP processes. * * @param int|null $pid Process ID (null/0 -> not running) * @param int $streamId Stream ID * @return bool */ public static function isStreamRunning(?int $pid, int $streamId) { $pid = (int) $pid; if ($pid <= 0) { return false; } if (!self::procExists($pid) || !is_readable('/proc/' . $pid . '/exe')) { return false; } $exe = @basename(@readlink('/proc/' . $pid . '/exe')); if (strpos($exe, 'ffmpeg') === 0) { $cmdline = trim(@file_get_contents('/proc/' . $pid . '/cmdline')); return (stristr($cmdline, '/' . $streamId . '_.m3u8') || stristr($cmdline, '/' . $streamId . '_%d.ts')); } // The fanout daemon's native remuxer (`xc_fanout remux … /_.m3u8`), // which produces a copy-only stream in ffmpeg's place. Its own subcommand // and this stream's playlist must both be there: the daemon process shares // the executable but names no stream playlist. if (strpos($exe, 'xc_fanout') === 0) { $cmdline = (string) @file_get_contents('/proc/' . $pid . '/cmdline'); return strpos($cmdline, "\0remux\0") !== false && strpos($cmdline, '/' . $streamId . '_.m3u8') !== false; } if (strpos($exe, 'php') === 0) { return true; } return false; } /** * What kind of producer a stream's pid is, for the panel's stream list. * * @param int|null $pid Process ID (null/0 -> not running) * @return string|null 'fanout' (xc_fanout remux), 'ffmpeg', 'php' (the LLOD * segmenter / loopback relay), or null when it cannot be read. */ public static function producerKind(?int $pid) { $pid = (int) $pid; if ($pid <= 0 || !self::procExists($pid) || !is_readable('/proc/' . $pid . '/exe')) { return null; } $exe = @basename(@readlink('/proc/' . $pid . '/exe')); if (strpos($exe, 'xc_fanout') === 0) { $cmdline = (string) @file_get_contents('/proc/' . $pid . '/cmdline'); return strpos($cmdline, "\0remux\0") !== false ? 'fanout' : null; } if (strpos($exe, 'ffmpeg') === 0) { return 'ffmpeg'; } if (strpos($exe, 'php') === 0) { return 'php'; } return null; } /** * One CPU/memory reading for a process, from /proc/PID/stat. * * CPU is cumulative (the ticks the process has burned since it started), so a * percentage needs two readings — see cpuPercent(). Memory is resident set * size in bytes, which is the figure that matters for a box running hundreds * of encoders: what they actually hold in RAM. * * @param int|null $pid Process ID (null/0 -> not running) * @return array{ticks:int,rss:int,at:float,start:int}|null Null when the process is gone. */ public static function resourceSample(?int $pid) { $pid = (int) $pid; if ($pid <= 1) { return null; } $stat = @file_get_contents('/proc/' . $pid . '/stat'); if (!is_string($stat) || $stat === '') { return null; } // Field 2 (comm) is parenthesised and may itself contain spaces and // brackets — "(ffmpeg (x))" is a legal name — so everything before the // LAST ')' is skipped and the split starts at field 3 (state). $rClose = strrpos($stat, ')'); if ($rClose === false) { return null; } $rFields = preg_split('/\s+/', trim(substr($stat, $rClose + 1))); if (!is_array($rFields) || count($rFields) < 22) { return null; } // $rFields[$i] is /proc/PID/stat field $i + 3: utime 14, stime 15, // starttime 22 (ticks after boot), rss 24. return [ 'ticks' => (int) $rFields[11] + (int) $rFields[12], 'rss' => (int) $rFields[21] * self::pageSize(), 'at' => microtime(true), 'start' => (int) $rFields[19], ]; } /** * CPU use averaged over the process's whole life, in percent of one core — * what `ps` reports. The fallback when there is no earlier sample to take a * difference against (the first reading of a producer), so a stream shows a * figure at once rather than a dash for a whole pass. * * The age comes from the process's own start time in /proc/PID/stat against * /proc/uptime, not from the mtime of /proc/PID: that inode is created when * something first looks at the directory, which for a long-running process * can be much later than its start. * * @param array $sample A resourceSample() reading. * @return float|null Null when the age cannot be read. */ public static function cpuPercentSinceStart(array $sample) { if (!isset($sample['ticks'], $sample['start'])) { return null; } $rUptime = (float) strtok((string) @file_get_contents('/proc/uptime'), ' '); $rAge = $rUptime - ((int) $sample['start'] / 100); if ($rUptime <= 0 || $rAge < 1) { return null; // a process younger than a second says nothing yet } return round(((int) $sample['ticks'] / 100) / $rAge * 100, 1); } /** * CPU use between two resourceSample() readings, in percent of one core. * * @param array $now The newer sample. * @param array $prev The older one (from the previous pass). * @return float|null Null when the pair says nothing: no previous reading, a * restarted process (its counter went backwards), or two * readings from the same instant. */ public static function cpuPercent(array $now, array $prev) { if (!isset($now['ticks'], $now['at'], $prev['ticks'], $prev['at'])) { return null; } $rSeconds = (float) $now['at'] - (float) $prev['at']; $rTicks = (int) $now['ticks'] - (int) $prev['ticks']; if ($rSeconds <= 0 || $rTicks < 0) { return null; } // /proc reports CPU time in USER_HZ, which is 100 on Linux whatever the // kernel's own tick rate — it is part of the /proc ABI, not CONFIG_HZ. return round(($rTicks / 100) / $rSeconds * 100, 1); } /** * The kernel page size, in bytes — /proc/PID/stat counts RSS in pages. * * Derived from this process's own two views of its memory (statm in pages, * status in kB) rather than assumed, because 64K pages are normal on arm64. * * @return int */ protected static function pageSize() { static $rSize = null; if ($rSize !== null) { return $rSize; } $rSize = 4096; $rStatm = @file_get_contents('/proc/self/statm'); $rStatus = @file_get_contents('/proc/self/status'); if (is_string($rStatm) && is_string($rStatus) && preg_match('/^VmRSS:\s+(\d+) kB/m', $rStatus, $rMatch)) { $rPages = (int) (preg_split('/\s+/', trim($rStatm))[1] ?? 0); if ($rPages > 0) { // The two files are read a moment apart, so snap the ratio to a // real page size instead of trusting it to the byte. $rRatio = ((int) $rMatch[1] * 1024) / $rPages; foreach ([4096, 8192, 16384, 65536] as $rCandidate) { if ($rRatio >= $rCandidate * 0.75 && $rRatio <= $rCandidate * 1.25) { $rSize = $rCandidate; break; } } } } return $rSize; } // ─────────────────────────────────────────────────────────── // Process Control // ─────────────────────────────────────────────────────────── /** * Kill a process by PID * * @param int|null $pid Process ID (null/0 -> not running) * @param int $signal Signal to send (default: SIGKILL = 9) * @return bool */ public static function kill(?int $pid, int $signal = 9) { $pid = (int) $pid; if ($pid <= 0) { return false; } if (!self::procExists($pid)) { return false; } return posix_kill($pid, $signal); } /** * Get the age of a process in seconds (how long it has been running). * * On Linux the mtime of the /proc/PID directory is fixed to the * process start time, so `time() - filemtime()` yields the same figure * as `ps -o etimes` without shelling out to ps — consistent with the * rest of this class reading /proc directly. * * Used to detect daemons that are still present but wedged (e.g. a * watchdog blocked in poll() on a half-open MariaDB socket): a normally * short-lived generation that has been alive far too long is stale. * * @param int|null $pid Process ID (null/0 -> not running) * @return int Age in seconds, or -1 if it cannot be determined */ public static function getProcessAge(?int $pid) { $pid = (int) $pid; if ($pid <= 0 || !self::procExists($pid)) { return -1; } clearstatcache(true, '/proc/' . $pid); $rStart = @filemtime('/proc/' . $pid); if ($rStart === false) { return -1; } $rAge = time() - $rStart; return $rAge > 0 ? $rAge : 0; } // ─────────────────────────────────────────────────────────── // Cron Lock Management // ─────────────────────────────────────────────────────────── /** * Acquire a cron lock (PID file) * * If a lock file exists with a running process, exits with 'Running...'. * If the process is stale (older than $timeout), kills it and takes over. * Creates a new lock file with the current PID. * * This replaces CoreUtilities::checkCron(). * * @param string $lockFile Path to PID lock file * @param int $timeout Maximum age in seconds before considering stale (default: 1800 = 30min) * @return bool Always returns true (exits on conflict) */ public static function acquireCronLock(string $lockFile, int $timeout = 1800) { if (file_exists($lockFile)) { // Read content + mtime up front. A competing cron can remove the lock // file between the exists() check and these reads (TOCTOU), which would // otherwise emit "failed to open stream" / "stat failed" warnings. If it // vanished, fall through and take the lock ourselves. $contents = @file_get_contents($lockFile); $mtime = @filemtime($lockFile); if ($contents !== false && $mtime !== false) { $pid = (int) trim($contents); if (self::procExists($pid)) { // Process is running — check if it's stale if (time() - $mtime >= $timeout) { // Stale — kill and take over if ($pid > 0) { posix_kill($pid, 9); } } else { // Still fresh — another instance is running exit('Running...'); } } } } // Write our PID $lockDir = dirname($lockFile); if (!is_dir($lockDir)) { @mkdir($lockDir, 0775, true); } // When running as root, chown created dirs to xc_vm so other // crons (cache, streams, etc.) can write into tmp/ subtree. if (posix_geteuid() === 0 && function_exists('posix_getpwnam') && defined('MAIN_HOME')) { $rUser = posix_getpwnam('xc_vm'); if ($rUser) { $rMainHome = rtrim(MAIN_HOME, '/'); $rDir = $lockDir; while ($rDir && $rDir !== $rMainHome && strlen($rDir) > strlen($rMainHome)) { if (is_dir($rDir) && fileowner($rDir) === 0) { @chown($rDir, $rUser['uid']); @chgrp($rDir, $rUser['gid']); } $rDir = dirname($rDir); } } } file_put_contents($lockFile, getmypid()); return true; } // ─────────────────────────────────────────────────────────── // Internal Helpers // ─────────────────────────────────────────────────────────── /** * Check if /proc/PID exists with caching * * @return bool */ protected static function procExists(int $pid) { $now = microtime(true); $key = (int) $pid; if (isset(self::$procCache[$key]) && ($now - self::$procCache[$key]['time']) < self::$cacheTtl) { return self::$procCache[$key]['exists']; } $exists = file_exists('/proc/' . $pid); self::$procCache[$key] = ['exists' => $exists, 'time' => $now]; return $exists; } /** * Clear the proc cache * * Useful before critical checks where stale cache could be dangerous. */ public static function clearCache() { self::$procCache = []; } // ─────────────────────────────────────────────────────────── // Streaming-specific Process Methods // Extracted from StreamingUtilities // ─────────────────────────────────────────────────────────── /** * Check if a stream process is alive (simplified cmdline search). * * Extracted from ProcessManager::isStreamAlive(). * Searches for $streamID anywhere in /proc/PID/cmdline (case-insensitive). * * @param int|null $pid Process ID (null/0 -> not running) * @param int|string $streamID Stream identifier to search for * @return bool */ public static function isStreamAlive(?int $pid, int|string $streamID) { $pid = (int) $pid; if ($pid <= 1) { return false; } if (!self::procExists($pid)) { return false; } if (!is_link('/proc/' . $pid . '/exe')) { return false; } static $cache = []; $cacheKey = $pid . '|' . $streamID; if (isset($cache[$cacheKey]) && $cache[$cacheKey]['time'] > time() - 4) { return $cache[$cacheKey]['alive']; } $cmd = @file_get_contents('/proc/' . $pid . '/cmdline'); if ($cmd === false) { $alive = false; } else { $cmd = str_replace("\0", ' ', $cmd); $alive = stripos($cmd, $streamID) !== false; } $cache[$cacheKey] = ['alive' => $alive, 'time' => time()]; return $alive; } /** * Check if a monitor/proxy process is running. * * Extracted from ProcessManager::isMonitorAlive(). * Checks for XC_VM[streamID] OR XC_VMProxy[streamID] in cmdline. * * @param int|null $pid Process ID (null/0 -> not running) * @param int|string $streamID Stream identifier * @param string|null $exe Expected executable (default: PHP_BIN) * @return bool */ public static function isMonitorAlive(?int $pid, int|string $streamID, ?string $exe = null) { $pid = (int) $pid; if ($pid <= 0) { return false; } if ($exe === null && defined('PHP_BIN')) { $exe = PHP_BIN; } if (!self::procExists($pid)) { return false; } if (!$exe || !is_readable('/proc/' . $pid . '/exe')) { return false; } if (strpos(basename(@readlink('/proc/' . $pid . '/exe')), basename($exe)) !== 0) { return false; } $cmdline = trim(@file_get_contents('/proc/' . $pid . '/cmdline')); return ($cmdline == 'XC_VM[' . $streamID . ']'); } /** * Start a stream monitor process in background. * * Always the PHP watchdog. Stream code calls StreamProcess::startMonitor() * instead, which hands the stream to the fanout daemon's supervisor when this * server supervises and only falls back to this. * * @return bool */ public static function startMonitor(int $streamID, int $restart = 0) { shell_exec(PHP_BIN . ' ' . MAIN_HOME . 'console.php monitor ' . intval($streamID) . ' ' . intval($restart) . ' >/dev/null 2>/dev/null &'); return true; } // ─────────────────────────────────────────────────────────── // Utility // ─────────────────────────────────────────────────────────── /** * Check if an nginx master process is running. * * Replaces CoreUtilities::isRunning(). * * The master is owned by root on typical installs (workers run as * xc_vm), so the scan must not be restricted to the xc_vm user — * a false negative here makes cron:servers/cron:streams bail out * every run and no daemon ever gets revived. * * @return bool */ public static function isNginxRunning() { foreach (glob('/proc/*/cmdline') ?: [] as $rCmdFile) { $rRaw = @file_get_contents($rCmdFile); if ($rRaw && strpos(str_replace("\0", ' ', $rRaw), 'nginx: master') !== false) { return true; } } return false; } /** * Find PIDs of processes whose command line contains one of the given * substrings. Reads /proc directly instead of ps|grep pipelines, which * match unrelated processes (e.g. ffmpeg's -thread_queue_size satisfied * the "queue" daemon check, so the encode queue was never revived). * * @param array $rTerms Cmdline substrings to match (exact, case-sensitive) * @param int $rLimit Stop after this many matches (0 = no limit) * @return array Matching PIDs (own PID excluded) */ public static function findProcessPIDs(array $rTerms, int $rLimit = 0) { $rPIDs = []; $rSelf = getmypid(); foreach (glob('/proc/[0-9]*/cmdline') ?: [] as $rCmdFile) { $rPID = intval(basename(dirname($rCmdFile))); if ($rPID == $rSelf) { continue; } $rRaw = @file_get_contents($rCmdFile); if (!$rRaw) { continue; } $rCmd = str_replace("\0", ' ', $rRaw); foreach ($rTerms as $rTerm) { if (strpos($rCmd, $rTerm) !== false) { $rPIDs[] = $rPID; if ($rLimit > 0 && count($rPIDs) >= $rLimit) { return $rPIDs; } break; } } } return $rPIDs; } /** * Check whether any process (any user) matches one of the given * cmdline substrings. * * @param array $rTerms Cmdline substrings to match * @return bool */ public static function isAnyProcessRunning(array $rTerms) { return count(self::findProcessPIDs($rTerms, 1)) > 0; } }