Prune obsolete PowerVR runtimes after guest processes exit

This commit is contained in:
wallentx committed 2026-10-11 05:26:03 -05:00
1 parent 0c9b059edc
commit ee1d861955
5 files changed
+180

No files matched your search

@@ -152,6 +152,28 @@ public final class SystemVulkanDriver {
return new File(new File(context.getFilesDir(), "system_vulkan"), version);
}
/** Collect abandoned stages, and obsolete versions once older guests can no longer load them. */
public static synchronized void pruneUnused(Context context, boolean guestsStopped) throws IOException {
pruneUnused(runtimeDirectory(context), guestsStopped);
}
static synchronized void pruneUnused(File current, boolean guestsStopped) {
File base = current.getParentFile();
if (base == null || Files.isSymbolicLink(base.toPath()) || Files.isSymbolicLink(current.toPath())) return;
File[] entries = base.listFiles();
if (entries == null) return;
// Retain the previous good version if publishing the replacement failed.
boolean pruneVersions = guestsStopped && complete(current);
for (File entry : entries) {
if (entry.equals(current)) continue;
String name = entry.getName();
boolean oldVersion = pruneVersions && name.matches("[0-9a-f]{64}");
boolean abandonedStage = name.matches("\\.stage-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}");
// The prepare monitor excludes any extraction still writing a .stage directory.
if (oldVersion || abandonedStage) FileUtils.delete(entry);
}
}
private static boolean complete(File directory) {
if (!new File(directory, "icd.json").isFile()) return false;
for (String name : REQUIRED) if (!present(directory, name)) return false;
@@ -27,6 +27,23 @@ object OrphanReaper {
fun release(pid: Int) { kept.remove(pid) }
/** Signal 0 checks ownership/liveness without sending a signal, even when /proc is unreadable. */
fun hasOtherProcesses(): Boolean = hasOtherProcesses(
File("/proc").list()?.mapNotNull { it.toIntOrNull() }, android.os.Process.myPid(),
) { pid ->
try {
android.system.Os.kill(pid, 0)
true
} catch (error: android.system.ErrnoException) {
error.errno != android.system.OsConstants.ESRCH && error.errno != android.system.OsConstants.EPERM
} catch (error: SecurityException) {
true
}
}
internal fun hasOtherProcesses(pids: List<Int>?, ownPid: Int, signalable: (Int) -> Boolean): Boolean =
pids == null || pids.any { it > 1 && it != ownPid && signalable(it) }
fun reap(reason: String): Int {
val me = android.os.Process.myPid()
val uid = android.os.Process.myUid()
@@ -336,6 +336,7 @@ class SessionService : Service() {
try {
if (SystemVulkanDriver.usesDefault(SessionPrefs.linuxDriver(this))) {
SystemVulkanDriver.prepare(this)
pruneSystemVulkanRuntime()
}
SessionFiles.stage(this, root)
} catch (error: Exception) {
@@ -1484,6 +1485,12 @@ class SessionService : Service() {
}.onFailure { Log.w(TAG, "could not request Steam wake", it) }
}
/** Background-only: preserve old runtimes until all earlier guest processes are gone. */
private fun pruneSystemVulkanRuntime() {
runCatching { SystemVulkanDriver.pruneUnused(this, !OrphanReaper.hasOtherProcesses()) }
.onFailure { Log.w(TAG, "could not prune unused system Vulkan runtimes", it) }
}
private fun finishSessionStop(status: Int, stoppedGen: Int) {
mainHandler.post {
if (stoppedGen != sessionGen || SessionState.running) {
@@ -1599,9 +1606,11 @@ class SessionService : Service() {
auxiliary.forEach { (pid, started) -> teardown(pid, started) }
if (prootPid > 1) teardown(prootPid)
killGuestLeftovers()
pruneSystemVulkanRuntime()
finishSessionStop(status, stoppedGen)
}, "session-teardown").start()
} else {
Thread({ pruneSystemVulkanRuntime() }, "system-vulkan-prune").start()
finishSessionStop(status, stoppedGen)
}
}
@@ -0,0 +1,109 @@
package com.droiddeck.launcher.gpu
import java.io.File
import java.nio.file.Files
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class SystemVulkanPruningTest {
@get:Rule val temporary = TemporaryFolder()
@Test fun completedReplacementCollectsOnlyManagedOldDirectories() {
val base = temporary.newFolder("system_vulkan")
val current = completeRuntime(base, "a".repeat(64))
val old = completeRuntime(base, "b".repeat(64))
val stage = abandonedStage(base)
val unrelated = File(base, "notes").apply { mkdir(); File(this, "keep").writeText("keep") }
SystemVulkanDriver.pruneUnused(current, true)
assertEquals("current ICD", File(current, "icd.json").readText())
assertTrue(File(current, "run-proton.py").canExecute())
assertFalse(old.exists())
assertFalse(stage.exists())
assertEquals("keep", File(unrelated, "keep").readText())
}
@Test fun aSurvivingGuestKeepsOldVersionsButNotAbandonedStages() {
val base = temporary.newFolder("system_vulkan")
val current = completeRuntime(base, "a".repeat(64))
val old = completeRuntime(base, "b".repeat(64))
val stage = abandonedStage(base)
SystemVulkanDriver.pruneUnused(current, false)
assertTrue(current.isDirectory)
assertTrue(old.isDirectory)
assertFalse(stage.exists())
SystemVulkanDriver.pruneUnused(current, true)
assertFalse("collect the old version once guests stop", old.exists())
}
@Test fun failedReplacementPreservesThePreviousGoodRuntime() {
val base = temporary.newFolder("system_vulkan")
val current = File(base, "a".repeat(64))
val old = completeRuntime(base, "b".repeat(64))
val stage = abandonedStage(base)
SystemVulkanDriver.pruneUnused(current, true)
assertTrue("missing replacement", old.isDirectory)
assertFalse(stage.exists())
current.mkdir()
File(current, "icd.json").writeText("incomplete")
SystemVulkanDriver.pruneUnused(current, true)
assertEquals("current ICD", File(old, "icd.json").readText())
}
@Test fun deletingOldRuntimeDoesNotFollowItsSymlinks() {
val base = temporary.newFolder("system_vulkan")
val current = completeRuntime(base, "a".repeat(64))
val old = File(base, "b".repeat(64)).apply { mkdir() }
val outside = temporary.newFolder("outside")
val sentinel = File(outside, "keep").apply { writeText("keep") }
Files.createSymbolicLink(File(old, "outside").toPath(), outside.toPath())
SystemVulkanDriver.pruneUnused(current, true)
assertFalse(old.exists())
assertEquals("keep", sentinel.readText())
}
@Test fun linkedBaseOrCurrentRuntimePreventsCleanup() {
val base = temporary.newFolder("system_vulkan")
val old = completeRuntime(base, "b".repeat(64))
val current = File(base, "a".repeat(64))
Files.createSymbolicLink(current.toPath(), old.toPath())
SystemVulkanDriver.pruneUnused(current, true)
assertTrue(old.isDirectory)
Files.delete(current.toPath())
completeRuntime(base, current.name)
val alias = File(temporary.root, "alias")
Files.createSymbolicLink(alias.toPath(), base.toPath())
SystemVulkanDriver.pruneUnused(File(alias, current.name), true)
assertTrue(old.isDirectory)
}
private fun abandonedStage(base: File) =
File(base, ".stage-12345678-1234-1234-1234-123456789abc").apply { mkdir(); File(this, "partial").writeText("partial") }
private fun completeRuntime(base: File, version: String): File {
val directory = File(base, version).apply { mkdir() }
val components = listOf(
"lib/libhybris-vulkan-icd.so.0", "lib/libhybris-common.so.1", "lib/libhybris/linker/q.so",
"lib/libVkLayer_hybris_compat.so", "run-proton.py",
"wsi/libVkLayer_window_system_integration.so", "wsi/VkLayer_window_system_integration.json",
) + listOf("x32", "x64").flatMap { arch ->
listOf("d3d8", "d3d9", "d3d10core", "d3d11", "dxgi").map { "dxvk/$arch/$it.dll" }
}
components.forEach { path -> File(directory, path).apply { parentFile!!.mkdirs(); writeText("component") } }
File(directory, "run-proton.py").setExecutable(true)
File(directory, "icd.json").writeText("current ICD")
return directory
}
}
@@ -0,0 +1,23 @@
package com.droiddeck.launcher.session
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class OrphanReaperProcessTest {
@Test fun unavailableProcessListDefersCleanup() {
assertTrue(OrphanReaper.hasOtherProcesses(null, 100) { false })
}
@Test fun theAppAndForeignProcessesDoNotKeepOldRuntimes() {
assertFalse(OrphanReaper.hasOtherProcesses(listOf(0, 1, 100, 200), 100) { pid ->
check(pid == 200) { "do not probe self or process groups" }
false
})
}
@Test fun anyRemainingAppProcessDefersCleanup() {
assertTrue(OrphanReaper.hasOtherProcesses(listOf(100, 200, 300), 100) { it == 300 })
assertFalse(OrphanReaper.hasOtherProcesses(emptyList(), 100) { true })
}
}