fix: give the session a live /proc/stat and read GPU load and clock where other overlays do

This commit is contained in:
xXJsonDeruloXx committed 2026-10-01 12:53:17 -04:00
1 parent 48054a86ab
commit b29ece82c8
4 files changed
+153 -22

No files matched your search

@@ -231,7 +231,10 @@ public final class LinuxRuntime {
};
for (String[] entry : procFiles) {
File fake = new File(fakeProc, entry[0]);
if (fake.isFile() && !new File(entry[1]).canRead()) {
// A live stand-in the session binds itself (CpuStatComponent's /proc/stat) wins.
boolean live = false;
if (extraBinds != null) for (String spec : extraBinds) live |= spec.endsWith(":" + entry[1]);
if (!live && fake.isFile() && !new File(entry[1]).canRead()) {
bind(cmd, fake.getPath() + ":" + entry[1]);
}
}
@@ -0,0 +1,110 @@
package com.droiddeck.launcher.session
import android.os.SystemClock
import android.util.Log
import com.droiddeck.launcher.core.SessionPart
import java.io.File
/**
* A `/proc/stat` that moves.
*
* Android has refused apps `/proc/stat` since 8.0, and the session's stand-in for it was one
* `cpu0` line of zeros - enough for glibc and Steam to start, but every CPU load in the session
* read 0 % (the performance overlay showed one core, idle). The kernel still lets apps read each
* core's idle residency (`cpuidle/stateN/time`, µs, the same label as the cpufreq files), which is
* how overlays on Android work out CPU load: a core's busy time is the time that passed less the
* time it spent idle. This writes `/proc/stat` from that, one line per core, refreshed twice a
* second. Counters only grow - a sample's idle is never more than the time that passed - so a
* reader taking the difference of two reads never sees a negative. Where `/proc/stat` is readable,
* or the idle times are not, nothing is done.
*/
class CpuStatComponent(val file: File) : SessionPart() {
@Volatile private var running = false
private var thread: Thread? = null
private val cores = (0 until coreCount()).toList()
private val busy = LongArray(cores.size)
private val idle = LongArray(cores.size)
private val lastIdleUs = LongArray(cores.size)
private var lastMs = 0L
private val bootSeconds = (System.currentTimeMillis() - SystemClock.elapsedRealtime()) / 1000
/** Writes the first sample; true when it should be bound over `/proc/stat`. */
fun prepare(): Boolean {
if (File("/proc/stat").canRead() || cores.isEmpty() || idleUs(0) == null) return false
lastMs = SystemClock.elapsedRealtime()
for (i in cores) lastIdleUs[i] = idleUs(i) ?: 0L
write()
Log.i(TAG, "hud: /proc/stat from the cores' idle times (${cores.size} cores)")
return true
}
override fun start() {
running = true
thread = Thread({
while (running) {
try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break }
if (!running) break
sample()
write()
}
}, "cpu-stat").apply { isDaemon = true; start() }
}
override fun stop() {
running = false
thread?.interrupt()
thread = null
}
private fun sample() {
val now = SystemClock.elapsedRealtime()
val elapsed = (now - lastMs) / MS_PER_TICK
if (elapsed <= 0) return
lastMs += elapsed * MS_PER_TICK
for (i in cores) {
// An offline core has no idle residency to count and runs nothing: all idle.
val us = idleUs(i)
val idleTicks = if (us == null) elapsed else ((us - lastIdleUs[i]) / US_PER_TICK).coerceIn(0, elapsed)
if (us != null) lastIdleUs[i] = us
idle[i] += idleTicks
busy[i] += elapsed - idleTicks
}
}
/** The whole file at once, renamed into place: a reader opens either the last sample or this one. */
private fun write() {
val text = StringBuilder()
text.append("cpu ${busy.sum()} 0 0 ${idle.sum()} 0 0 0 0 0 0\n")
for (i in cores) text.append("cpu$i ${busy[i]} 0 0 ${idle[i]} 0 0 0 0 0 0\n")
text.append("intr 0\nctxt 0\nbtime $bootSeconds\nprocesses 1\nprocs_running 1\nprocs_blocked 0\n")
try {
val tmp = File(file.parentFile, file.name + ".tmp")
tmp.writeText(text.toString())
if (!tmp.renameTo(file)) tmp.delete()
} catch (e: Exception) {
}
}
companion object {
private const val TAG = "SessionService"
private const val PERIOD_MS = 500L
/** /proc/stat counts in USER_HZ, 100 a second on every Linux the guest runs. */
private const val MS_PER_TICK = 10L
private const val US_PER_TICK = 10_000L
private fun coreCount(): Int =
File("/sys/devices/system/cpu").list()?.count { it.matches(Regex("cpu\\d+")) } ?: 0
/** The core's total idle residency in µs, or null when it has none to read (offline). */
private fun idleUs(core: Int): Long? {
val states = File("/sys/devices/system/cpu/cpu$core/cpuidle").listFiles { f -> f.name.startsWith("state") }
?: return null
if (states.isEmpty()) return null
var total = 0L
for (state in states) {
total += runCatching { File(state, "time").readText().trim().toLong() }.getOrNull() ?: return null
}
return total
}
}
}
@@ -14,9 +14,10 @@ import java.io.RandomAccessFile
* 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
* there instead: `gpu_busy_percentage` and `clock_mhz` from Qualcomm's `/sys/kernel/gpu` (load
* and MHz, under the plain sysfs label - where other Android overlays read them), else from KGSL's
* `gpubusy` (busy and total cycles of the last sample) and `gpuclk` (Hz), files with labels of
* their own that policies often leave to apps for GPU profilers; 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.
*/
@@ -32,14 +33,12 @@ class GpuStatsComponent(val dir: File) : SessionPart() {
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 busy = readBusy()?.also { writeLoad(it) } != null
val clk = readClock()?.also { write(clock, String.format("%5d\n", it)) } != null
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"))
Log.i(TAG, "hud: kgsl stats from the session's own: load " + (if (busy) loadFrom else "none") +
", clock " + (if (clk) clockFrom else "none") + ", temp " + (temp ?: "none"))
true
} catch (e: Exception) {
Log.w(TAG, "hud: could not stand in for kgsl stats: $e")
@@ -66,20 +65,30 @@ class GpuStatsComponent(val dir: File) : SessionPart() {
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
private var loadFrom = ""
private var clockFrom = ""
/** GPU load in percent: /sys/kernel/gpu's "NN %", else KGSL's busy and total cycles. */
private fun readBusy(): Int? {
runCatching { File(QCOM_GPU, "gpu_busy").readText().trim().removeSuffix("%").trim().toInt() }.getOrNull()?.let {
loadFrom = "$QCOM_GPU/gpu_busy"
return it.coerceIn(0, 100)
}
return runCatching {
val parts = File(KGSL, "gpubusy").readText().trim().split(Regex("\\s+"))
val total = parts[1].toLong()
if (total > 0) (parts[0].toLong() * 100 / total).toInt().coerceIn(0, 100) else 0
}.getOrNull()?.also { loadFrom = "$KGSL/gpubusy" }
}
/** 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
/** GPU clock in MHz: /sys/kernel/gpu's, else KGSL's gpuclk in Hz. */
private fun readClock(): Int? {
runCatching { File(QCOM_GPU, "gpu_clock").readText().trim().toInt() }.getOrNull()?.let {
clockFrom = "$QCOM_GPU/gpu_clock"
return it
}
return runCatching { (File(KGSL, "gpuclk").readText().trim().toLong() / 1_000_000).toInt() }.getOrNull()
?.also { clockFrom = "$KGSL/gpuclk" }
}
private fun writeLoad(percent: Int) = write(load, String.format("%3d %%\n", percent))
@@ -95,6 +104,8 @@ class GpuStatsComponent(val dir: File) : SessionPart() {
companion object {
private const val TAG = "SessionService"
const val KGSL = "/sys/class/kgsl/kgsl-3d0"
/** Qualcomm's own GPU summary, beside KGSL in vendor kernels. */
private const val QCOM_GPU = "/sys/kernel/gpu"
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")
// CPU load for everything in the session that reads /proc/stat, the overlay among them.
val cpuStat = CpuStatComponent(File(filesDir, "session/proc-stat"))
if (cpuStat.prepare()) {
cpuStat.attach(this)
components.add(cpuStat)
binds.add(cpuStat.file.path + ":/proc/stat")
}
// 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()) {