From 511d7be7a9bfa342aec8d3096cac4e3dc270e0db Mon Sep 17 00:00:00 2001 From: martijn Date: Fri, 4 Jul 2025 14:13:13 +0200 Subject: [PATCH] Initial profiler POC --- furnace.php | 270 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 furnace.php diff --git a/furnace.php b/furnace.php new file mode 100644 index 0000000..19e2465 --- /dev/null +++ b/furnace.php @@ -0,0 +1,270 @@ +lastTimestamp = time(); + } + + public function run(): void { + Logger::debug("Profiler started"); + echo "\n[Profiler] Started at " . date('c') . "\n"; + while (true) { + $now = time(); + $delta = $now - $this->lastTimestamp; + $this->lastTimestamp = $now; + + $pids = ProcessFinder::findPhpFpmPids(PROC_NAME); + foreach (/*array_slice($pids, 0, 5)*/$pids as $pid) { + Logger::debug("Profiling PID {$pid}"); + $data = $this->collectStats($pid, $delta); + if ($data) { + Logger::log($data); + } + } + if (empty($pids)) { + Logger::debug("No PHP-FPM worker processes found"); + } + sleep($this->interval); + } + } + + private function collectStats(int $pid, int $delta): ?array { + $stat = @file_get_contents("/proc/{$pid}/stat"); + $status = @file_get_contents("/proc/{$pid}/status"); + $environ = @file_get_contents("/proc/{$pid}/environ"); + if (!$stat || !$status) return null; + + $fields = explode(' ', $stat); + $utime = (int)$fields[13]; + $stime = (int)$fields[14]; + $totalCpu = $utime + $stime; + $cpuDelta = isset($this->lastCpuTimes[$pid]) ? $totalCpu - $this->lastCpuTimes[$pid] : 0; + $this->lastCpuTimes[$pid] = $totalCpu; + + $memory = (int)ProcessParser::parseKeyValue($status, 'VmRSS'); + $threads = (int)ProcessParser::parseKeyValue($status, 'Threads'); + $fdCount = count(glob("/proc/{$pid}/fd/*")); + $uri = ProcessParser::parseEnv($environ, 'REQUEST_URI'); + $stack = GDB::getStack($pid); + + $this->callCounts[$pid] = ($this->callCounts[$pid] ?? 0) + 1; + + return [ + 'time' => time(), + 'delta' => $delta, + 'pid' => $pid, + 'calls' => $this->callCounts[$pid], + 'cpu_time_ticks' => $totalCpu, + 'cpu_delta' => $cpuDelta, + 'memory_kb' => $memory, + 'threads' => $threads, + 'fds' => $fdCount, + 'request_uri' => $uri, + 'stack' => $stack, + ]; + } +} + +class Logger { + public static function log(array $data): void { + $json = json_encode($data, JSON_UNESCAPED_SLASHES); + file_put_contents(STREAM_FILE, "data: $json\n\n", FILE_APPEND); + //echo "[Log] " . date('c') . " - PID {$data['pid']} - Calls: {$data['calls']} - Memory: {$data['memory_kb']} KB - CPU Δ: {$data['cpu_delta']} - FDs: {$data['fds']}, Threads: {$data['threads']}\n"; + } + + public static function debug(string $msg): void { + $json = json_encode(['debug' => $msg, 'time' => time()]); + file_put_contents(STREAM_FILE, "data: $json\n\n", FILE_APPEND); + //echo "[Debug] " . date('c') . " - $msg\n"; + } +} + +class GDB { + public static function getStack(int $pid): array { + $cmdFile = tempnam(sys_get_temp_dir(), 'gdb'); + file_put_contents($cmdFile, "set pagination off\nthread apply all bt " . MAX_FRAMES . "\nquit\n"); + + $output = []; + $cmd = "timeout 5s sudo gdb -q -p $pid -x $cmdFile 2>&1"; + exec($cmd, $output, $code); + unlink($cmdFile); + + if ($code !== 0) { + Logger::debug("GDB failed for PID $pid with exit code $code"); + return ["[gdb failed or timed out]"]; + } + + return array_filter($output, fn($l) => str_starts_with(trim($l), '#')); + } +} + +class ProcessFinder +{ + public static function findPhpFpmPids(string $name): array + { + $pids = []; + foreach (glob('/proc/[0-9]*/status') as $statusFile) { + $pid = (int)basename(dirname($statusFile)); + $status = @file_get_contents($statusFile); + if ($status && preg_match('/^Name:\s+(.*)$/m', $status, $match)) { + $procName = trim($match[1]); + if (stripos($procName, 'php-fpm') !== false) { + $pids[] = $pid; + //Logger::debug("Matched PHP-FPM process: PID {$pid}"); + } + } + } + return $pids; + } +} + +class ProcessParser { + public static function parseKeyValue(string $text, string $key): ?string { + if (preg_match("/^$key:\\s+(\\d+)/m", $text, $m)) { + return $m[1]; + } + return null; + } + + public static function parseEnv(string $env, string $key): ?string { + if (preg_match("/$key=([^\\x00]+)/", $env, $m)) { + return urldecode($m[1]); + } + return null; + } +} + +class StreamServer { + public static function start(): void { + $server = stream_socket_server("tcp://0.0.0.0:" . PORT, $errno, $errstr); + if (!$server) die("Server error: $errstr\n"); + echo "[StreamServer] Listening on http://localhost:" . PORT . "\n"; + + while ($conn = @stream_socket_accept($server)) { + stream_set_blocking($conn, false); + $request = fread($conn, 1024); + if (preg_match('#GET /stream#', $request)) { + fwrite($conn, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\n\r\n"); + fwrite($conn, ": stream initialized\n\n"); + fflush($conn); + + $pos = 0; + $lastPing = time(); + while (!feof($conn)) { + clearstatcache(); + $len = file_exists(STREAM_FILE) ? filesize(STREAM_FILE) : 0; + if ($len > $pos) { + $f = fopen(STREAM_FILE, 'r'); + fseek($f, $pos); + while (!feof($f)) { + fwrite($conn, fgets($f)); + } + $pos = ftell($f); + fclose($f); + } + + if ((time() - $lastPing) >= 10) { + fwrite($conn, ": keep-alive\n\n"); + fflush($conn); + $lastPing = time(); + } + + usleep(250_000); + } + fclose($conn); + } else { + fwrite($conn, "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n"); + fwrite($conn, << + + + + PHP-Furnace Viewer + + + +

🔥 PHP-Furnace Live Stream

+
Waiting for profiler data...
+ + + +HTML +); + fclose($conn); + } + } + } +} + + +// ────── Fork to Run Stream Server in Background ────── +if (function_exists('pcntl_fork')) { + $pid = pcntl_fork(); + if ($pid === -1) { + die("Failed to fork\n"); + } elseif ($pid === 0) { + // Child process: Stream server + StreamServer::start(); + exit(0); + } +} else { + die("PCNTL extension is required to run this script.\n"); +} + +// ────── Start Profiler ────── +(new Profiler(INTERVAL))->run(); \ No newline at end of file