fix: keep the performance overlay running and fed under an enforcing SELinux policy

This commit is contained in:
xXJsonDeruloXx committed 2026-10-01 12:38:30 -04:00
1 parent 6782e5a717
commit 48054a86ab
3 files changed
+202 -2

No files matched your search

@@ -238,6 +238,7 @@ public final class LinuxRuntime {
bindGpuNode(context, cmd);
bindAdrenoStats(cmd);
bindCpuTemps(cmd, root);
bindCpuHwmon(context.getCacheDir(), cmd);
if (extraBinds != null) {
for (String spec : extraBinds) bind(cmd, spec);
}
@@ -282,12 +283,94 @@ public final class LinuxRuntime {
if (!Files.isSymbolicLink(subsystem.toPath())) {
Os.symlink("/sys/bus/platform", subsystem.getPath());
}
// What MangoHud names a GPU's driver by; msm_drm is how an Adreno's render node reads
// on a mainline kernel, and the driver it reads the load of (bindAdrenoStats).
File driver = new File(device, "driver");
if (!Files.isSymbolicLink(driver.toPath())) {
Os.symlink("/sys/bus/platform/drivers/msm_drm", driver.getPath());
}
} catch (IOException | ErrnoException e) {
return;
}
bind(cmd, new File(base, "sys").getPath() + ":/sys/dev/char");
bind(cmd, dri.getPath() + ":/dev/dri");
bind(cmd, KGSL_DEVICE + ":/dev/dri/" + node);
bindDrmClass(base, cmd, node, major + ":" + minor);
}
/**
* MangoHud (Deck mode's performance overlay, mangoapp) finds the GPU by listing
* {@code /sys/class/drm}, and an exception from that listing ends the process: under an
* enforcing SELinux policy - every retail phone - an app may not list it, so mangoapp died on
* start, gamescope restarted it until the session gave up, and there was no overlay. Where the
* app cannot list it, the session's {@code /sys/class/drm} is ours: the render node above,
* named as an Adreno's is. MangoHud then reads the GPU's stats from KGSL's sysfs, which such a
* device refuses too - SessionService's GpuStatsComponent stands in for that. Where the
* listing is allowed it is left alone.
*/
private static void bindDrmClass(File base, List<String> cmd, String node, String devNumbers) {
if (new File("/sys/class/drm").list() != null) return;
File drmClass = new File(base, "class");
try {
if (!drmClass.isDirectory() && !drmClass.mkdirs()) return;
// Only symlinks are ever made here; deleting them must not follow one into its target.
File[] old = drmClass.listFiles();
if (old != null) for (File f : old) Files.deleteIfExists(f.toPath());
Os.symlink("/sys/dev/char/" + devNumbers, new File(drmClass, node).getPath());
} catch (IOException | ErrnoException e) {
return;
}
bind(cmd, drmClass.getPath() + ":/sys/class/drm");
android.util.Log.i("LinuxRuntime", "hud: /sys/class/drm is not listable here; the session's lists " + node);
// Also refused to list, also asked for by MangoHud twice a second - an error line each
// time in the session log - and holding nothing it reads on an Adreno phone.
if (new File("/sys/class/powercap").list() == null) {
File empty = new File(base, "empty-powercap");
if (empty.isDirectory() || empty.mkdirs()) bind(cmd, empty.getPath() + ":/sys/class/powercap");
}
}
/**
* MangoHud's CPU temperature: it looks first in {@code /sys/class/hwmon} for a sensor named
* {@code cpuN_thermal} (a mainline Arm kernel's), then at thermal zones of a type it knows; an
* Android kernel has neither - its hwmon holds the PMIC's sensors and its zones are named by
* the vendor (cpu-1-0-usr) - so the overlay showed no CPU temperature, and where the app may
* not list hwmon the overlay logged an error twice a second. The session's hwmon is one sensor
* named so, reading a CPU zone the app can read. Nothing else in a phone's hwmon is read by it.
*/
private static void bindCpuHwmon(File cacheDir, List<String> cmd) {
String zone = null;
for (java.util.Map.Entry<String, File> e : new java.util.TreeMap<>(thermalZones()).entrySet()) {
File temp = new File(e.getValue(), "temp");
if (e.getKey().contains("cpu") && !e.getKey().contains("gpu") && temp.canRead()) {
zone = temp.getPath();
break;
}
}
if (zone == null) return;
File sensor = new File(cacheDir, "hwmon/hwmon0");
try {
if (!sensor.isDirectory() && !sensor.mkdirs()) return;
Files.write(new File(sensor, "name").toPath(), "cpu0_thermal\n".getBytes(StandardCharsets.US_ASCII));
File input = new File(sensor, "temp1_input");
Files.deleteIfExists(input.toPath());
Os.symlink(zone, input.getPath());
} catch (IOException | ErrnoException e) {
return;
}
bind(cmd, sensor.getParent() + ":/sys/class/hwmon");
android.util.Log.i("LinuxRuntime", "hud: cpu temp from " + zone);
}
/** The file the GPU's temperature reads from, or null where the app may read none. */
public static String gpuTempSource() {
String kgsl = "/sys/class/kgsl/kgsl-3d0/";
return firstReadable(kgsl + "temp", kgsl + "devfreq/temp", thermalZone("gpu"));
}
private static String gpuLoadSource() {
String kgsl = "/sys/class/kgsl/kgsl-3d0/";
return firstReadable(kgsl + "gpu_busy_percentage", kgsl + "devfreq/gpu_load");
}
/**
@@ -300,9 +383,9 @@ public final class LinuxRuntime {
*/
private static void bindAdrenoStats(List<String> cmd) {
String kgsl = "/sys/class/kgsl/kgsl-3d0/";
String gpuTemp = firstReadable(kgsl + "temp", kgsl + "devfreq/temp", thermalZone("gpu"));
String gpuTemp = gpuTempSource();
String[][] stats = {
{firstReadable(kgsl + "gpu_busy_percentage", kgsl + "devfreq/gpu_load"), "/sys/kernel/debug/dri/0/perf_now"},
{gpuLoadSource(), "/sys/kernel/debug/dri/0/perf_now"},
{firstReadable(kgsl + "devfreq/cur_freq", kgsl + "gpuclk"),
"/sys/devices/platform/soc@0/3d00000.gpu/devfreq/3d00000.gpu/cur_freq"},
{gpuTemp, "/sys/class/thermal/thermal_zone28/temp"},
@@ -312,6 +395,16 @@ public final class LinuxRuntime {
for (String[] stat : stats) {
if (stat[0] != null) bind(cmd, stat[0] + ":" + stat[1]);
}
// Which of the overlay's GPU values this device lets the app read: a phone under an
// enforcing policy refuses some of them, and the overlay then has no line for that value.
android.util.Log.i("LinuxRuntime", "hud sources: gpu load " + orNone(stats[0][0]) + ", gpu clock "
+ orNone(stats[1][0]) + ", gpu temp " + orNone(gpuTemp) + ", ddr temp " + orNone(stats[4][0])
+ ", thermal zones " + thermalZones().size()
+ ", kgsl gpubusy " + (new File(kgsl + "gpubusy").canRead() ? "readable" : "refused"));
}
private static String orNone(String path) {
return path != null ? path : "none";
}
/**
@@ -0,0 +1,100 @@
package com.droiddeck.launcher.session
import android.system.Os
import android.util.Log
import com.droiddeck.launcher.core.SessionPart
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import java.io.RandomAccessFile
/**
* The Adreno's stats as mangoapp (Deck mode's performance overlay) reads them.
*
* MangoHud takes an Adreno's load and temperature from `/sys/class/kgsl/kgsl-3d0` and ends the
* process on a file there it may not read - not a file that is missing, which it skips. Under an
* enforcing SELinux policy (every retail phone) KGSL's sysfs is refused to apps, so mangoapp died
* on start and the session ran with no overlay. Where that is so, a directory of our own is bound
* there instead: `gpu_busy_percentage` worked out from `gpubusy` (busy and total cycles of the
* last sample) and `clock_mhz` from `gpuclk` - files with labels of their own that policies often
* leave to apps for GPU profilers - when they are readable, and `temp` linked to the GPU's
* thermal zone when there is one. What is not readable is left out, and the overlay shows no line
* for it. Where KGSL's own files are readable, nothing is done.
*/
class GpuStatsComponent(val dir: File) : SessionPart() {
@Volatile private var running = false
private var thread: Thread? = null
private val load = File(dir, "gpu_busy_percentage")
private val clock = File(dir, "clock_mhz")
/** Writes the directory; true when it should be bound over [KGSL]. */
fun prepare(): Boolean {
if (!File(KGSL).exists() || File(KGSL, "gpu_busy_percentage").canRead()) return false
return try {
dir.mkdirs()
dir.listFiles()?.forEach { java.nio.file.Files.deleteIfExists(it.toPath()) }
val busy = File(KGSL, "gpubusy").canRead()
if (busy) writeLoad(0)
val clk = File(KGSL, "gpuclk").canRead()
if (clk) readClock()?.let { write(clock, String.format("%5d\n", it)) }
val temp = LinuxRuntime.gpuTempSource()
if (temp != null) Os.symlink(temp, File(dir, "temp").path)
Log.i(TAG, "hud: kgsl stats from the session's own: load " + (if (busy) "from gpubusy" else "none") +
", clock " + (if (clk) "from gpuclk" else "none") + ", temp " + (temp ?: "none"))
true
} catch (e: Exception) {
Log.w(TAG, "hud: could not stand in for kgsl stats: $e")
false
}
}
override fun start() {
if (!load.exists() && !clock.exists()) return
running = true
thread = Thread({
while (running) {
try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break }
if (!running) break
if (load.exists()) readBusy()?.let { writeLoad(it) }
if (clock.exists()) readClock()?.let { write(clock, String.format("%5d\n", it)) }
}
}, "gpu-stats").apply { isDaemon = true; start() }
}
override fun stop() {
running = false
thread?.interrupt()
thread = null
}
private fun readBusy(): Int? = try {
val parts = File(KGSL, "gpubusy").readText().trim().split(Regex("\\s+"))
val busy = parts[0].toLong()
val total = parts[1].toLong()
if (total > 0) (busy * 100 / total).toInt().coerceIn(0, 100) else 0
} catch (e: Exception) {
null
}
/** The GPU clock in MHz; gpuclk is in Hz. */
private fun readClock(): Int? = try {
(File(KGSL, "gpuclk").readText().trim().toLong() / 1_000_000).toInt()
} catch (e: Exception) {
null
}
private fun writeLoad(percent: Int) = write(load, String.format("%3d %%\n", percent))
/** In place and at one length, so a reader that keeps the file open sees each value whole. */
private fun write(file: File, text: String) {
try {
RandomAccessFile(file, "rw").use { it.seek(0); it.write(text.toByteArray()) }
} catch (e: Exception) {
}
}
companion object {
private const val TAG = "SessionService"
const val KGSL = "/sys/class/kgsl/kgsl-3d0"
private const val PERIOD_MS = 500L
}
}
@@ -688,6 +688,13 @@ class SessionService : Service() {
battery.attach(this)
components.add(battery)
binds.add(battery.dir.path + ":/sys/class/power_supply")
// The GPU's load and temperature for the performance overlay, where KGSL's sysfs is refused.
val gpuStats = GpuStatsComponent(File(filesDir, "session/sys/kgsl-3d0"))
if (gpuStats.prepare()) {
gpuStats.attach(this)
components.add(gpuStats)
binds.add(gpuStats.dir.path + ":" + GpuStatsComponent.KGSL)
}
// Rumble for the on-screen pad: the fake evdev layer sends force-feedback effects to this
// listener, which drives the phone's vibrator (see RumbleComponent).
if (controllersOn) components.add(RumbleComponent().also { it.attach(this) })