mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-07 10:46:06 -10:00
Add enhanced one-session diagnostic bundles
This commit is contained in:
1 parent
b563b8d1eb
commit
5614bd5d5a
32 files changed
+3100
-141
No files matched your search
@@ -2,6 +2,7 @@ tools/linuxfs/** text=auto eol=lf
|
||||
tools/mangoapp/** text=auto eol=lf
|
||||
tools/comet/LICENSE whitespace=-blank-at-eof
|
||||
tools/comet/droiddeck.patch whitespace=-blank-at-eol,-blank-at-eof
|
||||
tools/gamescope/patches/0900-enhanced-frame-diagnostics.patch whitespace=-blank-at-eol,-blank-at-eof,-space-before-tab
|
||||
|
||||
tools/linuxfs/overlay/** text eol=lf
|
||||
tools/linuxfs/desktop/** text eol=lf
|
||||
|
||||
@@ -577,6 +577,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private var emulatorList by mutableStateOf<List<Library.Emulator>>(emptyList())
|
||||
private var runningLabel by mutableStateOf<String?>(null)
|
||||
private var logsEnabled by mutableStateOf(true)
|
||||
private var enhancedDiagnosticsArmed by mutableStateOf(false)
|
||||
private var gameDebugLogsEnabled by mutableStateOf(false)
|
||||
private var gameDebugLogsNeedsRestart by mutableStateOf(false)
|
||||
private var vacDiagnostics by mutableStateOf(false)
|
||||
@@ -1136,6 +1137,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
desktopInstalled = desktopInstalled, desktopPresent = desktopPresent,
|
||||
offlineAccount = offlineAccount, offline = offline,
|
||||
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
|
||||
enhancedDiagnosticsArmed = enhancedDiagnosticsArmed,
|
||||
gameDebugLogsEnabled = gameDebugLogsEnabled, gameDebugLogsNeedsRestart = gameDebugLogsNeedsRestart,
|
||||
vacDiagnostics = vacDiagnostics, agentCommands = agentCommands,
|
||||
clearCachedApps = clearCachedApps, adbPaired = adbPaired, memoryHud = memoryHud, backgroundAppLimit = backgroundAppLimit,
|
||||
@@ -1327,6 +1329,15 @@ class FrontEndActivity : ComponentActivity() {
|
||||
gameDebugLogsEnabled = SessionPrefs.gameDebugLogsEnabled(this)
|
||||
gameDebugLogsNeedsRestart = gameDebugLogsNeedsRestart()
|
||||
},
|
||||
onEnhancedDiagnostics = {
|
||||
val requested = !com.droiddeck.launcher.session.EnhancedDiagnostics.isArmed(this)
|
||||
if (!com.droiddeck.launcher.session.EnhancedDiagnostics.setArmed(this, requested)) {
|
||||
android.widget.Toast.makeText(
|
||||
this, R.string.setup_logging_update_failed, android.widget.Toast.LENGTH_LONG,
|
||||
).show()
|
||||
}
|
||||
enhancedDiagnosticsArmed = com.droiddeck.launcher.session.EnhancedDiagnostics.isArmed(this)
|
||||
},
|
||||
onGameDebugLogs = {
|
||||
val changed = SessionPrefs.setGameDebugLogsEnabled(this, !SessionPrefs.gameDebugLogsEnabled(this))
|
||||
if (!changed) android.widget.Toast.makeText(
|
||||
@@ -1350,9 +1361,19 @@ class FrontEndActivity : ComponentActivity() {
|
||||
if (limit == com.droiddeck.launcher.core.BackgroundAppLimit.OFF) com.droiddeck.launcher.core.BackgroundAppLimit.restoreAsync(this)
|
||||
},
|
||||
onShareLogs = {
|
||||
SessionLogShare.prepare(this, { SessionLogShare.latest(this) }) { zip ->
|
||||
if (zip == null) android.widget.Toast.makeText(this, R.string.main_no_logs_yet, android.widget.Toast.LENGTH_LONG).show()
|
||||
else startActivity(SessionLogShare.shareIntent(this, zip))
|
||||
val folder = SessionLogShare.latest(this)
|
||||
if (folder == null) {
|
||||
android.widget.Toast.makeText(this, R.string.main_no_logs_yet, android.widget.Toast.LENGTH_LONG).show()
|
||||
} else if (com.droiddeck.launcher.session.EnhancedDiagnostics.isActive(this, folder)) {
|
||||
SessionLogShare.prepareEnhanced(this, { folder }) { zips ->
|
||||
if (zips.isEmpty()) android.widget.Toast.makeText(this, R.string.main_no_logs_yet, android.widget.Toast.LENGTH_LONG).show()
|
||||
else shareEnhancedArchives(zips)
|
||||
}
|
||||
} else {
|
||||
SessionLogShare.prepare(this, { folder }) { zip ->
|
||||
if (zip == null) android.widget.Toast.makeText(this, R.string.main_no_logs_yet, android.widget.Toast.LENGTH_LONG).show()
|
||||
else startActivity(SessionLogShare.shareIntent(this, zip))
|
||||
}
|
||||
}
|
||||
},
|
||||
onShareSessionLog = { folder ->
|
||||
@@ -2688,6 +2709,26 @@ class FrontEndActivity : ComponentActivity() {
|
||||
SessionPrefs.logsEnabled(this) && SessionPrefs.gameDebugLogsEnabled(this) &&
|
||||
!SessionPaths.currentSessionLogsRetained(this)
|
||||
|
||||
private fun shareEnhancedArchives(zips: List<java.io.File>) {
|
||||
val choices = zips.map { zip ->
|
||||
val label = when {
|
||||
zip.name.endsWith("-session.zip") -> getString(R.string.diagnostics_share_session)
|
||||
zip.name.endsWith("-graphics.zip") -> getString(R.string.diagnostics_share_graphics)
|
||||
else -> getString(R.string.diagnostics_share_games)
|
||||
}
|
||||
label to listOf(zip)
|
||||
} + (getString(R.string.diagnostics_share_all) to zips)
|
||||
android.app.AlertDialog.Builder(this)
|
||||
.setTitle(R.string.diagnostics_share_title)
|
||||
.setItems(choices.map { it.first }.toTypedArray()) { _, which ->
|
||||
val selected = choices[which].second
|
||||
startActivity(if (selected.size == 1) SessionLogShare.shareIntent(this, selected.single())
|
||||
else SessionLogShare.shareEnhancedIntent(this, selected))
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
val listX64 = SessionPrefs.activeSteamClientArch(this) == "x86_64"
|
||||
if (listX64 != steamGamesX64) {
|
||||
@@ -2712,6 +2753,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
frameGenLabel = FrameGen.label(this)
|
||||
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
|
||||
logsEnabled = SessionPrefs.logsEnabled(this)
|
||||
enhancedDiagnosticsArmed = com.droiddeck.launcher.session.EnhancedDiagnostics.isArmed(this)
|
||||
gameDebugLogsEnabled = SessionPrefs.gameDebugLogsEnabled(this)
|
||||
gameDebugLogsNeedsRestart = gameDebugLogsNeedsRestart()
|
||||
vacDiagnostics = SessionPrefs.vacDiagnostics(this)
|
||||
|
||||
@@ -1884,7 +1884,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
Toast.makeText(this, R.string.session_no_logs, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
SessionLogShare.prepare(this, { folder }) { zip ->
|
||||
if (com.droiddeck.launcher.session.EnhancedDiagnostics.isActive(this, folder)) {
|
||||
SessionLogShare.prepareEnhanced(this, { folder }) { zips ->
|
||||
if (zips.isEmpty()) {
|
||||
Toast.makeText(this, R.string.session_logs_archive_failed, Toast.LENGTH_LONG).show()
|
||||
} else runCatching { shareEnhancedArchives(zips) }
|
||||
.onFailure {
|
||||
Log.w(TAG, "could not share current session logs", it)
|
||||
Toast.makeText(this, R.string.session_logs_share_failed, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
} else SessionLogShare.prepare(this, { folder }) { zip ->
|
||||
if (zip == null) {
|
||||
Toast.makeText(this, R.string.session_logs_archive_failed, Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
@@ -1897,6 +1907,26 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareEnhancedArchives(zips: List<File>) {
|
||||
val choices = zips.map { zip ->
|
||||
val label = when {
|
||||
zip.name.endsWith("-session.zip") -> getString(R.string.diagnostics_share_session)
|
||||
zip.name.endsWith("-graphics.zip") -> getString(R.string.diagnostics_share_graphics)
|
||||
else -> getString(R.string.diagnostics_share_games)
|
||||
}
|
||||
label to listOf(zip)
|
||||
} + (getString(R.string.diagnostics_share_all) to zips)
|
||||
android.app.AlertDialog.Builder(this)
|
||||
.setTitle(R.string.diagnostics_share_title)
|
||||
.setItems(choices.map { it.first }.toTypedArray()) { _, which ->
|
||||
val selected = choices[which].second
|
||||
startActivity(if (selected.size == 1) SessionLogShare.shareIntent(this, selected.single())
|
||||
else SessionLogShare.shareEnhancedIntent(this, selected))
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun readPrefs() {
|
||||
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
|
||||
unfoldedControls = SessionPrefs.unfoldedControls(this)
|
||||
|
||||
@@ -131,42 +131,81 @@ object LogRedactor {
|
||||
@Volatile
|
||||
private var accounts: List<Learned> = emptyList()
|
||||
|
||||
fun learnAccounts(vararg loginUsers: java.io.File) {
|
||||
internal const val MAX_LEARN_INPUT_BYTES = 2 * 1024 * 1024
|
||||
private const val MAX_LEARNED_ACCOUNTS = 2048
|
||||
private const val MAX_LEARNED_ADDRESSES = 1024
|
||||
private const val MAX_ACCOUNT_LENGTH = 256
|
||||
|
||||
private fun boundedText(file: java.io.File): String? = try {
|
||||
file.inputStream().buffered().use { input ->
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
var remaining = MAX_LEARN_INPUT_BYTES
|
||||
while (remaining > 0) {
|
||||
val read = input.read(buffer, 0, minOf(buffer.size, remaining))
|
||||
if (read < 0) return out.toString(Charsets.UTF_8.name())
|
||||
if (read == 0) continue
|
||||
out.write(buffer, 0, read)
|
||||
remaining -= read
|
||||
}
|
||||
if (input.read() != -1) null else out.toString(Charsets.UTF_8.name())
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun learnAccounts(vararg loginUsers: java.io.File): Boolean {
|
||||
val names = LinkedHashSet<String>()
|
||||
val kv = Regex("\"(AccountName|PersonaName)\"\\s+\"([^\"]*)\"")
|
||||
var unread = false
|
||||
for (file in loginUsers) {
|
||||
if (!file.isFile) continue
|
||||
val text = try { file.readText() } catch (e: Exception) { unread = true; continue }
|
||||
kv.findAll(text).map { it.groupValues[2].trim() }.filter { it.length >= 3 }.forEach { names += it }
|
||||
val text = boundedText(file)
|
||||
if (text == null) {
|
||||
unread = true
|
||||
continue
|
||||
}
|
||||
for (match in kv.findAll(text)) {
|
||||
val name = match.groupValues[2].trim()
|
||||
if (name.length < 3) continue
|
||||
if (name.length > MAX_ACCOUNT_LENGTH || names.size >= MAX_LEARNED_ACCOUNTS) {
|
||||
unread = true
|
||||
break
|
||||
}
|
||||
names += name
|
||||
}
|
||||
}
|
||||
val learned = names.map { name ->
|
||||
val needs = name.lowercase().takeIf { name.all { it.code < 0x80 } }
|
||||
Learned(Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])"), needs)
|
||||
}
|
||||
accounts = if (unread) (accounts + learned).distinctBy { it.regex.pattern } else learned
|
||||
accounts = if (unread) (learned + accounts).distinctBy { it.regex.pattern }.take(MAX_LEARNED_ACCOUNTS) else learned
|
||||
return !unread
|
||||
}
|
||||
|
||||
fun learnFromRuntime(root: java.io.File) {
|
||||
learnOwnAddresses(java.io.File(root, "etc/droiddeck-net"))
|
||||
learnAccounts(
|
||||
fun learnFromRuntime(root: java.io.File): Boolean {
|
||||
val addressesComplete = learnOwnAddresses(java.io.File(root, "etc/droiddeck-net"))
|
||||
val accountsComplete = learnAccounts(
|
||||
java.io.File(root, "root/.local/share/Steam/config/loginusers.vdf"),
|
||||
java.io.File(root, "root/.droiddeck-x64/.local/share/Steam/config/loginusers.vdf"),
|
||||
)
|
||||
matchers.remove()
|
||||
return addressesComplete && accountsComplete
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var own: List<Learned> = emptyList()
|
||||
|
||||
fun learnOwnAddresses(linkFile: java.io.File) {
|
||||
fun learnOwnAddresses(linkFile: java.io.File): Boolean {
|
||||
val found = ArrayList<Learned>()
|
||||
try {
|
||||
if (linkFile.isFile) linkFile.forEachLine { line ->
|
||||
val addr = line.trim().takeIf { it.startsWith("addr ") }?.split(Regex("\\s+"))?.getOrNull(1) ?: return@forEachLine
|
||||
val text = if (linkFile.isFile) boundedText(linkFile) ?: return false else ""
|
||||
text.lineSequence().forEach { line ->
|
||||
val addr = line.trim().takeIf { it.startsWith("addr ") }?.split(Regex("\\s+"))?.getOrNull(1) ?: return@forEach
|
||||
if (found.size >= MAX_LEARNED_ADDRESSES) return false
|
||||
when (kind(addr)) {
|
||||
"public IPv6" -> {
|
||||
val groups = expand6(addr)?.take(4) ?: return@forEachLine
|
||||
val groups = expand6(addr)?.take(4) ?: return@forEach
|
||||
val prefix = groups.joinToString(":") { g -> "0{0,3}" + Regex.escape(g.trimStart('0').ifEmpty { "0" }) }
|
||||
found += Learned(
|
||||
Regex("(?i)(?<![0-9A-Fa-f:])$prefix(?::[0-9A-Fa-f]{0,4}){1,4}(?:%[A-Za-z0-9_.]+)?"),
|
||||
@@ -177,9 +216,10 @@ object LogRedactor {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
own = found
|
||||
return true
|
||||
}
|
||||
|
||||
fun kind(address: String): String? {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
object EnhancedDiagnostics {
|
||||
const val MANIFEST_FILE = "enhanced-diagnostics.json"
|
||||
|
||||
private const val TAG = "EnhancedDiagnostics"
|
||||
private const val PREFS = "session"
|
||||
private const val ARMED = "enhancedDiagnosticsNext"
|
||||
private const val ACTIVE_RECORDS = "enhancedDiagnosticsActiveSessions"
|
||||
private const val SCHEMA = 1
|
||||
|
||||
fun isArmed(context: Context): Boolean =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(ARMED, false)
|
||||
|
||||
fun setArmed(context: Context, armed: Boolean): Boolean =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(ARMED, armed).commit()
|
||||
|
||||
fun isActive(context: Context, folder: File?): Boolean = activeAt(context, folder) != null
|
||||
|
||||
internal fun isCurrentSessionActive(context: Context): Boolean = isActive(context, SessionPaths.current())
|
||||
|
||||
fun manifest(context: Context, folder: File?): JSONObject? {
|
||||
val activeAt = activeAt(context, folder) ?: return null
|
||||
return buildManifest(folder!!, activeAt)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
internal fun consumeInto(context: Context, folder: File): Boolean {
|
||||
if (!isArmed(context)) return isActive(context, folder)
|
||||
if (!folder.isDirectory) return false
|
||||
val activatedAt = System.currentTimeMillis()
|
||||
if (!activateAndConsume(context, folder, activatedAt)) return false
|
||||
val marker = AtomicFile(File(folder, MANIFEST_FILE))
|
||||
val manifest = buildManifest(folder, activatedAt)
|
||||
val output = try {
|
||||
marker.startWrite()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "could not start the informational enhanced diagnostics manifest", error)
|
||||
return true
|
||||
}
|
||||
try {
|
||||
output.write((manifest.toString() + "\n").toByteArray(Charsets.UTF_8))
|
||||
marker.finishWrite(output)
|
||||
} catch (error: Exception) {
|
||||
marker.failWrite(output)
|
||||
Log.w(TAG, "could not write the informational enhanced diagnostics manifest", error)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
internal fun clearRecordFor(context: Context, folder: File) {
|
||||
removeRecord(context, folder)
|
||||
}
|
||||
|
||||
private fun buildManifest(folder: File, activatedAt: Long) = JSONObject()
|
||||
.put("schema", SCHEMA)
|
||||
.put("profile", "enhanced")
|
||||
.put("sessionId", folder.name)
|
||||
.put("activatedAtEpochMs", activatedAt)
|
||||
.put("exportBundlesBounded", true)
|
||||
.put("rawCaptureByteBounded", false)
|
||||
.put("rawCaptureLimitation", "Game and component diagnostics may grow until the session ends")
|
||||
.put("collectorsRequested", JSONArray(listOf(
|
||||
"session", "graphics", "games", "processes", "audio", "network", "components",
|
||||
)))
|
||||
|
||||
private fun activeAt(context: Context, folder: File?): Long? {
|
||||
val path = trustedPath(context, folder) ?: return null
|
||||
return records(context).optLong(path, Long.MIN_VALUE).takeIf { it != Long.MIN_VALUE }
|
||||
}
|
||||
|
||||
private fun activateAndConsume(context: Context, folder: File, activatedAt: Long): Boolean {
|
||||
val path = trustedPath(context, folder) ?: return false
|
||||
val records = records(context)
|
||||
records.keys().asSequence().toList().forEach { known ->
|
||||
if (!File(known).isDirectory) records.remove(known)
|
||||
}
|
||||
records.put(path, activatedAt)
|
||||
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString(ACTIVE_RECORDS, records.toString())
|
||||
.putBoolean(ARMED, false)
|
||||
.commit()
|
||||
}
|
||||
|
||||
private fun removeRecord(context: Context, folder: File) {
|
||||
val path = runCatching { folder.canonicalPath }.getOrNull() ?: return
|
||||
val records = records(context)
|
||||
if (records.has(path)) {
|
||||
records.remove(path)
|
||||
saveRecords(context, records)
|
||||
}
|
||||
}
|
||||
|
||||
private fun trustedPath(context: Context, folder: File?): String? {
|
||||
if (folder == null || !SessionPaths.isSessionFolder(folder)) return null
|
||||
return runCatching {
|
||||
val canonical = folder.canonicalFile
|
||||
if (canonical.parentFile != SessionFiles.logDirectory(context).canonicalFile) null else canonical.path
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun records(context: Context): JSONObject {
|
||||
val text = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(ACTIVE_RECORDS, null)
|
||||
return runCatching { JSONObject(text ?: "{}") }.getOrDefault(JSONObject())
|
||||
}
|
||||
|
||||
private fun saveRecords(context: Context, records: JSONObject): Boolean =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString(ACTIVE_RECORDS, records.toString()).commit()
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.system.Os
|
||||
import android.system.OsConstants
|
||||
import com.droiddeck.launcher.core.LogRedactor
|
||||
import com.droiddeck.launcher.runtime.BattleNet
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.FileInputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.Channels
|
||||
import java.nio.channels.FileChannel
|
||||
import java.nio.channels.SeekableByteChannel
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.LinkOption
|
||||
import java.nio.file.Path
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
internal data class EnhancedShareLimits(
|
||||
val maxFilesPerArchive: Int = 256,
|
||||
val maxDiscoveredFiles: Int = 768,
|
||||
val maxBytesPerFile: Long = 8L * 1024 * 1024,
|
||||
val maxBytesTotal: Long = 64L * 1024 * 1024,
|
||||
)
|
||||
|
||||
internal object EnhancedSessionLogShare {
|
||||
private enum class Category(val suffix: String) { SESSION("session"), GRAPHICS("graphics"), GAMES("games") }
|
||||
|
||||
private data class Candidate(
|
||||
val file: File,
|
||||
val anchor: File,
|
||||
val entry: String,
|
||||
val origin: String,
|
||||
val category: Category,
|
||||
val linePrefix: String? = null,
|
||||
)
|
||||
|
||||
private data class Record(
|
||||
val entry: String,
|
||||
val origin: String,
|
||||
val status: String,
|
||||
val sourceBytes: Long = 0,
|
||||
val capturedBytes: Long = 0,
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
private data class Snapshot(val file: File, val sourceBytes: Long, val truncated: Boolean)
|
||||
|
||||
private data class CollectionResult(val candidates: List<Candidate>, val truncated: Boolean)
|
||||
|
||||
private data class ScanResult(val files: List<File>, val truncated: Boolean)
|
||||
|
||||
private data class LegacySource(val file: File, val root: File, val entry: String)
|
||||
|
||||
private class Budget(val limit: Long, var remaining: Long = limit)
|
||||
|
||||
fun create(
|
||||
context: Context,
|
||||
folder: File,
|
||||
onProgress: ((Float) -> Unit)? = null,
|
||||
limits: EnhancedShareLimits = EnhancedShareLimits(),
|
||||
): List<File> {
|
||||
if (!folder.isDirectory || Files.isSymbolicLink(folder.toPath()) ||
|
||||
limits.maxFilesPerArchive < 1 || limits.maxBytesPerFile < 1 || limits.maxBytesTotal < 1) {
|
||||
return emptyList()
|
||||
}
|
||||
val out = SessionLogShare.shareDir(context).apply { deleteRecursively(); mkdirs() }
|
||||
val stage = File(out, ".diagnostic-stage").apply { mkdirs() }
|
||||
val redactionLearningComplete = LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
val discovery = collect(context, folder, limits.maxDiscoveredFiles, legacy = false)
|
||||
val candidates = discovery.candidates
|
||||
val totalCandidates = candidates.size.coerceAtLeast(1)
|
||||
val share = limits.maxBytesTotal / Category.values().size
|
||||
val budgets = Category.values().associateWith { category ->
|
||||
Budget(if (category == Category.GAMES) limits.maxBytesTotal - share * 2 else share)
|
||||
}
|
||||
var completed = 0
|
||||
return try {
|
||||
Category.values().map { category ->
|
||||
val budget = budgets.getValue(category)
|
||||
val records = ArrayList<Record>()
|
||||
val selected = candidates.filter { it.category == category }
|
||||
val zip = File(out, "DroidDeck-${folder.name}-${category.suffix}.zip")
|
||||
ZipOutputStream(zip.outputStream().buffered()).use { archive ->
|
||||
val names = HashSet<String>()
|
||||
selected.forEachIndexed { index, candidate ->
|
||||
val safeEntry = safeEntry(candidate.entry)
|
||||
val record = when {
|
||||
index >= limits.maxFilesPerArchive -> Record(
|
||||
safeEntry ?: "<invalid>", candidate.origin, "omitted", reason = "file-count-limit",
|
||||
)
|
||||
safeEntry == null -> Record("<invalid>", candidate.origin, "omitted", reason = "unsafe-entry-name")
|
||||
!names.add(safeEntry) -> Record(safeEntry, candidate.origin, "omitted", reason = "duplicate-entry")
|
||||
else -> addCandidate(archive, candidate.copy(entry = safeEntry), stage, budget, limits)
|
||||
}
|
||||
records += record
|
||||
completed++
|
||||
onProgress?.invoke((completed.toFloat() / totalCandidates).coerceIn(0f, 1f))
|
||||
}
|
||||
if (category == Category.SESSION) addTrustedActivation(context, folder, archive)?.let(records::add)
|
||||
if (selected.isEmpty()) records += Record("", category.suffix, "unavailable", reason = "no-matching-artifacts")
|
||||
if (discovery.truncated) records += Record("", "discovery", "truncated", reason = "candidate-limit")
|
||||
addManifest(
|
||||
archive, folder.name, category.suffix, records, limits, budget.limit, redactionLearningComplete,
|
||||
)
|
||||
}
|
||||
zip
|
||||
}
|
||||
} finally {
|
||||
stage.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
fun createLegacy(
|
||||
context: Context,
|
||||
folder: File,
|
||||
onProgress: ((Float) -> Unit)? = null,
|
||||
limits: EnhancedShareLimits = EnhancedShareLimits(maxFilesPerArchive = 512),
|
||||
): File? {
|
||||
if (!folder.isDirectory || Files.isSymbolicLink(folder.toPath()) ||
|
||||
limits.maxFilesPerArchive < 1 || limits.maxBytesPerFile < 1 || limits.maxBytesTotal < 1) {
|
||||
return null
|
||||
}
|
||||
val out = SessionLogShare.shareDir(context).apply { deleteRecursively(); mkdirs() }
|
||||
val redactionLearningComplete = LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
val discovery = collect(context, folder, limits.maxDiscoveredFiles, legacy = true)
|
||||
val candidates = discovery.candidates.filter { it.linePrefix == null }
|
||||
if (candidates.isEmpty()) {
|
||||
out.deleteRecursively()
|
||||
return null
|
||||
}
|
||||
val stage = File(out, ".diagnostic-stage").apply { mkdirs() }
|
||||
val records = ArrayList<Record>()
|
||||
val budget = Budget(limits.maxBytesTotal)
|
||||
val zip = File(out, "DroidDeck-${folder.name}.zip")
|
||||
return try {
|
||||
ZipOutputStream(zip.outputStream().buffered()).use { archive ->
|
||||
val names = HashSet<String>()
|
||||
candidates.forEachIndexed { index, candidate ->
|
||||
val safeEntry = safeEntry(candidate.entry)
|
||||
records += when {
|
||||
index >= limits.maxFilesPerArchive -> Record(
|
||||
safeEntry ?: "<invalid>", candidate.origin, "omitted", reason = "file-count-limit",
|
||||
)
|
||||
safeEntry == null -> Record("<invalid>", candidate.origin, "omitted", reason = "unsafe-entry-name")
|
||||
!names.add(safeEntry) -> Record(safeEntry, candidate.origin, "omitted", reason = "duplicate-entry")
|
||||
else -> addCandidate(archive, candidate.copy(entry = safeEntry), stage, budget, limits)
|
||||
}
|
||||
onProgress?.invoke((index + 1f) / candidates.size)
|
||||
}
|
||||
addTrustedActivation(context, folder, archive)?.let(records::add)
|
||||
if (discovery.truncated) records += Record("", "discovery", "truncated", reason = "candidate-limit")
|
||||
addManifest(archive, folder.name, "all", records, limits, budget.limit, redactionLearningComplete)
|
||||
}
|
||||
zip
|
||||
} finally {
|
||||
stage.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun addTrustedActivation(
|
||||
context: Context,
|
||||
folder: File,
|
||||
archive: ZipOutputStream,
|
||||
): Record? {
|
||||
val manifest = EnhancedDiagnostics.manifest(context, folder) ?: return null
|
||||
val bytes = (manifest.toString(2) + "\n").toByteArray()
|
||||
val entry = "${folder.name}/${EnhancedDiagnostics.MANIFEST_FILE}"
|
||||
archive.putNextEntry(ZipEntry(entry))
|
||||
archive.write(bytes)
|
||||
archive.closeEntry()
|
||||
return Record(entry, "app-private-activation-state", "captured", bytes.size.toLong(), bytes.size.toLong())
|
||||
}
|
||||
|
||||
private fun addCandidate(
|
||||
archive: ZipOutputStream,
|
||||
candidate: Candidate,
|
||||
stage: File,
|
||||
budget: Budget,
|
||||
limits: EnhancedShareLimits,
|
||||
): Record {
|
||||
if (budget.remaining <= 0) {
|
||||
return Record(candidate.entry, candidate.origin, "omitted", reason = "bundle-byte-limit")
|
||||
}
|
||||
val snapshot = runCatching {
|
||||
snapshot(candidate, stage, minOf(limits.maxBytesPerFile, budget.remaining))
|
||||
}.getOrElse {
|
||||
return Record(candidate.entry, candidate.origin, "error", reason = it.javaClass.simpleName)
|
||||
} ?: return Record(candidate.entry, candidate.origin, "omitted", reason = "unsafe-or-unavailable-source")
|
||||
if (snapshot.file.length() == 0L && candidate.linePrefix != null) {
|
||||
snapshot.file.delete()
|
||||
return Record(candidate.entry, candidate.origin, "unavailable", snapshot.sourceBytes, reason = "no-matching-lines")
|
||||
}
|
||||
return try {
|
||||
budget.remaining -= snapshot.file.length()
|
||||
archive.putNextEntry(ZipEntry(candidate.entry))
|
||||
val writer = archive.bufferedWriter()
|
||||
LogRedactor.scrubForShare(snapshot.file, writer)
|
||||
writer.flush()
|
||||
archive.closeEntry()
|
||||
Record(
|
||||
candidate.entry,
|
||||
candidate.origin,
|
||||
if (snapshot.truncated) "truncated" else "captured",
|
||||
snapshot.sourceBytes,
|
||||
snapshot.file.length(),
|
||||
if (snapshot.truncated) "per-file-or-bundle-byte-limit" else null,
|
||||
)
|
||||
} finally {
|
||||
snapshot.file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshot(candidate: Candidate, stage: File, limit: Long): Snapshot? {
|
||||
val rootPath = candidate.anchor.toPath().toAbsolutePath().normalize()
|
||||
val sourcePath = candidate.file.toPath().toAbsolutePath().normalize()
|
||||
if (!sourcePath.startsWith(rootPath) || sourcePath == rootPath || limit < 1) return null
|
||||
val target = File.createTempFile("diagnostic-", ".log", stage)
|
||||
try {
|
||||
val relative = rootPath.relativize(sourcePath)
|
||||
return withSecureChannel(rootPath, relative) { input ->
|
||||
val size = input.size()
|
||||
candidate.linePrefix?.let { prefix ->
|
||||
val truncated = filterChannel(input, target, prefix, size, TRACE_SCAN_BYTES, limit)
|
||||
return@withSecureChannel Snapshot(target, size, truncated)
|
||||
}
|
||||
FileOutputStream(target).channel.use { output ->
|
||||
if (size <= limit) {
|
||||
copy(input, output, 0, size)
|
||||
} else {
|
||||
val marker = "\n<diagnostic file truncated: beginning and tail retained>\n".toByteArray()
|
||||
val content = (limit - marker.size).coerceAtLeast(0)
|
||||
copyCompleteHead(input, output, content / 2)
|
||||
val markerBytes = minOf(marker.size.toLong(), limit - output.position()).toInt()
|
||||
output.write(ByteBuffer.wrap(marker, 0, markerBytes))
|
||||
copyCompleteTail(input, output, size, (limit - output.position()).coerceAtLeast(0))
|
||||
}
|
||||
output.force(false)
|
||||
}
|
||||
Snapshot(target, size, size > limit)
|
||||
} ?: target.delete().let { null }
|
||||
} catch (e: Exception) {
|
||||
target.delete()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterChannel(
|
||||
channel: SeekableByteChannel,
|
||||
target: File,
|
||||
prefix: String,
|
||||
sourceSize: Long,
|
||||
scanLimit: Long,
|
||||
outputLimit: Long,
|
||||
): Boolean {
|
||||
val wanted = prefix.toByteArray()
|
||||
var written = 0L
|
||||
var truncated = sourceSize > scanLimit
|
||||
target.outputStream().buffered().use { output ->
|
||||
fun scan(start: Long, count: Long, discardPartialFirstLine: Boolean) {
|
||||
channel.position(start)
|
||||
val input = Channels.newInputStream(channel)
|
||||
val line = ByteArrayOutputStream(1024)
|
||||
var oversized = false
|
||||
var scanned = 0L
|
||||
var discard = discardPartialFirstLine
|
||||
fun emit() {
|
||||
val bytes = line.toByteArray()
|
||||
if (!discard && !oversized && bytes.size >= wanted.size && wanted.indices.all { bytes[it] == wanted[it] }) {
|
||||
if (written + bytes.size + 1 <= outputLimit) {
|
||||
output.write(bytes)
|
||||
output.write('\n'.code)
|
||||
written += bytes.size + 1
|
||||
} else truncated = true
|
||||
}
|
||||
line.reset()
|
||||
oversized = false
|
||||
discard = false
|
||||
}
|
||||
while (scanned < count) {
|
||||
val value = input.read()
|
||||
if (value < 0) break
|
||||
scanned++
|
||||
if (value == '\n'.code) emit()
|
||||
else if (!oversized && !discard) {
|
||||
if (line.size() >= LogRedactor.MAX_LINE_LENGTH) {
|
||||
oversized = true
|
||||
line.reset()
|
||||
} else line.write(value)
|
||||
}
|
||||
}
|
||||
if (start + scanned >= sourceSize && (line.size() > 0 || oversized)) emit()
|
||||
}
|
||||
if (sourceSize <= scanLimit) {
|
||||
scan(0, sourceSize, false)
|
||||
} else {
|
||||
val head = scanLimit / 2
|
||||
val tail = scanLimit - head
|
||||
scan(0, head, false)
|
||||
scan((sourceSize - tail).coerceAtLeast(0), tail, true)
|
||||
}
|
||||
}
|
||||
return truncated
|
||||
}
|
||||
|
||||
private fun <T> withSecureChannel(root: Path, relative: Path, block: (SeekableByteChannel) -> T): T? {
|
||||
val opened = ArrayList<ParcelFileDescriptor>()
|
||||
return try {
|
||||
val parts = relative.map { it.toString() }
|
||||
if (parts.isEmpty()) return null
|
||||
val rootFd = openDescriptor(root.toString()) ?: return null
|
||||
if (!OsConstants.S_ISDIR(Os.fstat(rootFd).st_mode)) {
|
||||
Os.close(rootFd)
|
||||
return null
|
||||
}
|
||||
opened += duplicateAndClose(rootFd)
|
||||
for (part in parts.dropLast(1)) {
|
||||
val childFd = openDescriptor(procChild(opened.last(), part)) ?: return null
|
||||
if (!OsConstants.S_ISDIR(Os.fstat(childFd).st_mode)) {
|
||||
Os.close(childFd)
|
||||
return null
|
||||
}
|
||||
opened += duplicateAndClose(childFd)
|
||||
}
|
||||
val leafFd = openDescriptor(procChild(opened.last(), parts.last())) ?: return null
|
||||
val stat = Os.fstat(leafFd)
|
||||
if (!OsConstants.S_ISREG(stat.st_mode) || stat.st_nlink != 1L) {
|
||||
Os.close(leafFd)
|
||||
return null
|
||||
}
|
||||
val stream = try {
|
||||
FileInputStream(leafFd)
|
||||
} catch (error: Exception) {
|
||||
Os.close(leafFd)
|
||||
throw error
|
||||
}
|
||||
stream.use { block(it.channel) }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} finally {
|
||||
opened.asReversed().forEach { runCatching { it.close() } }
|
||||
}
|
||||
}
|
||||
|
||||
private fun openDescriptor(path: String): java.io.FileDescriptor? = runCatching {
|
||||
Os.open(
|
||||
path,
|
||||
OsConstants.O_RDONLY or OsConstants.O_CLOEXEC or OsConstants.O_NOFOLLOW or OsConstants.O_NONBLOCK,
|
||||
0,
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
private fun duplicateAndClose(fd: java.io.FileDescriptor): ParcelFileDescriptor =
|
||||
try {
|
||||
ParcelFileDescriptor.dup(fd)
|
||||
} finally {
|
||||
Os.close(fd)
|
||||
}
|
||||
|
||||
private fun procChild(directory: ParcelFileDescriptor, name: String): String =
|
||||
"/proc/self/fd/${directory.fd}/$name"
|
||||
|
||||
private fun copy(input: SeekableByteChannel, output: FileChannel, start: Long, count: Long) {
|
||||
input.position(start)
|
||||
var left = count
|
||||
val buffer = ByteBuffer.allocate(64 * 1024)
|
||||
while (left > 0) {
|
||||
buffer.clear()
|
||||
buffer.limit(minOf(buffer.capacity().toLong(), left).toInt())
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
buffer.flip()
|
||||
while (buffer.hasRemaining()) output.write(buffer)
|
||||
left -= read
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyCompleteHead(input: SeekableByteChannel, output: FileChannel, limit: Long) {
|
||||
if (limit <= 0) return
|
||||
copy(input, output, 0, limit)
|
||||
val copied = output.position()
|
||||
input.position(0)
|
||||
val buffer = ByteBuffer.allocate(64 * 1024)
|
||||
var scanned = 0L
|
||||
var lastComplete = 0L
|
||||
while (scanned < copied) {
|
||||
buffer.clear()
|
||||
buffer.limit(minOf(buffer.capacity().toLong(), copied - scanned).toInt())
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
buffer.flip()
|
||||
repeat(read) { index ->
|
||||
if (buffer.get(index) == '\n'.code.toByte()) lastComplete = scanned + index + 1
|
||||
}
|
||||
scanned += read
|
||||
}
|
||||
output.truncate(lastComplete)
|
||||
output.position(lastComplete)
|
||||
}
|
||||
|
||||
private fun copyCompleteTail(
|
||||
input: SeekableByteChannel,
|
||||
output: FileChannel,
|
||||
sourceSize: Long,
|
||||
limit: Long,
|
||||
) {
|
||||
if (limit <= 0 || sourceSize <= 0) return
|
||||
var start = (sourceSize - limit).coerceAtLeast(0)
|
||||
if (start > 0) {
|
||||
input.position(start)
|
||||
val one = ByteBuffer.allocate(1)
|
||||
while (start < sourceSize) {
|
||||
one.clear()
|
||||
if (input.read(one) < 0) return
|
||||
start++
|
||||
one.flip()
|
||||
if (one.get() == '\n'.code.toByte()) break
|
||||
}
|
||||
}
|
||||
copy(input, output, start, (sourceSize - start).coerceAtMost(limit))
|
||||
}
|
||||
|
||||
private fun addManifest(
|
||||
archive: ZipOutputStream,
|
||||
sessionId: String,
|
||||
category: String,
|
||||
records: List<Record>,
|
||||
limits: EnhancedShareLimits,
|
||||
categoryBudget: Long,
|
||||
redactionLearningComplete: Boolean,
|
||||
) {
|
||||
val entries = JSONArray()
|
||||
records.take(MAX_MANIFEST_RECORDS).forEach { record ->
|
||||
entries.put(JSONObject().apply {
|
||||
put("entry", record.entry)
|
||||
put("origin", record.origin)
|
||||
put("status", record.status)
|
||||
put("sourceBytes", record.sourceBytes)
|
||||
put("capturedBytes", record.capturedBytes)
|
||||
record.reason?.let { put("reason", it) }
|
||||
})
|
||||
}
|
||||
if (records.size > MAX_MANIFEST_RECORDS) {
|
||||
entries.put(JSONObject().apply {
|
||||
put("entry", "")
|
||||
put("origin", "manifest")
|
||||
put("status", "omitted")
|
||||
put("reason", "${records.size - MAX_MANIFEST_RECORDS} additional records")
|
||||
})
|
||||
}
|
||||
val manifest = JSONObject().apply {
|
||||
put("schema", 1)
|
||||
put("sessionId", sessionId)
|
||||
put("category", category)
|
||||
put("generatedAtMillis", System.currentTimeMillis())
|
||||
put("redactionLearning", if (redactionLearningComplete) "complete" else "incomplete")
|
||||
put("limits", JSONObject().apply {
|
||||
put("maxFilesPerArchive", limits.maxFilesPerArchive)
|
||||
put("maxDiscoveredFiles", limits.maxDiscoveredFiles)
|
||||
put("maxBytesPerFile", limits.maxBytesPerFile)
|
||||
put("budgetBasis", "raw-input-snapshot-bytes")
|
||||
put("maxRawInputBytesTotal", limits.maxBytesTotal)
|
||||
put("categoryRawInputBudgetBytes", categoryBudget)
|
||||
})
|
||||
put("entries", entries)
|
||||
}.toString(2) + "\n"
|
||||
archive.putNextEntry(ZipEntry("diagnostics-manifest.json"))
|
||||
archive.write(manifest.toByteArray())
|
||||
archive.closeEntry()
|
||||
}
|
||||
|
||||
private fun collect(context: Context, folder: File, maxFiles: Int, legacy: Boolean): CollectionResult {
|
||||
val out = LinkedHashMap<String, Candidate>()
|
||||
val categoryCounts = HashMap<Category, Int>()
|
||||
val perCategory = (maxFiles / Category.values().size).coerceAtLeast(1)
|
||||
var truncated = false
|
||||
fun add(
|
||||
file: File,
|
||||
root: File,
|
||||
entry: String,
|
||||
origin: String,
|
||||
forcedCategory: Category? = null,
|
||||
linePrefix: String? = null,
|
||||
) {
|
||||
val safe = safeEntry(entry) ?: return
|
||||
val category = forcedCategory ?: category(entry)
|
||||
if (out.size >= maxFiles || (categoryCounts[category] ?: 0) >= perCategory) {
|
||||
truncated = true
|
||||
return
|
||||
}
|
||||
val anchor = anchorFor(context, root)
|
||||
if (out.putIfAbsent(safe, Candidate(file, anchor, safe, origin, category, linePrefix)) == null) {
|
||||
categoryCounts[category] = (categoryCounts[category] ?: 0) + 1
|
||||
}
|
||||
}
|
||||
CRITICAL_SESSION_FILES.forEach { name ->
|
||||
File(folder, name).takeIf { it.exists() }?.let { add(it, folder, "${folder.name}/$name", "session-critical") }
|
||||
}
|
||||
File(folder, "session.log").takeIf { it.exists() }?.let { file ->
|
||||
add(
|
||||
file, folder, "${folder.name}/graphics/frame-trace.log", "guest-frame-trace",
|
||||
Category.GRAPHICS, "DROIDDECK_FRAME_TRACE ",
|
||||
)
|
||||
add(
|
||||
file, folder, "${folder.name}/graphics/process-trace.log", "guest-process-trace",
|
||||
Category.GRAPHICS, "DROIDDECK_PROCESS_TRACE ",
|
||||
)
|
||||
add(
|
||||
file, folder, "${folder.name}/games/game-lifecycle.log", "guest-game-lifecycle",
|
||||
Category.GAMES, "DROIDDECK_DIAGNOSTICS event=game_",
|
||||
)
|
||||
}
|
||||
if (SessionPaths.current()?.canonicalFile == folder.canonicalFile && !File(folder, "steam").exists()) {
|
||||
val steam = File(SessionPrefs.activeSteamRoot(context), "logs")
|
||||
val scan = directFiles(steam, remaining(maxFiles, out.size))
|
||||
truncated = truncated || scan.truncated
|
||||
scan.files.forEach {
|
||||
add(it, steam, "${folder.name}/steam/${it.name}", "live-steam")
|
||||
}
|
||||
}
|
||||
val logs = LinuxRuntime.logDir(context)
|
||||
for ((name, root) in listOf(SessionLogShare.STORES_DIR to File(logs, SessionLogShare.STORES_DIR), SessionPaths.TOOLS_DIR to File(logs, SessionPaths.TOOLS_DIR))) {
|
||||
val scan = directFiles(root, remaining(maxFiles, out.size))
|
||||
truncated = truncated || scan.truncated
|
||||
scan.files.asSequence().filter { recent(it) }
|
||||
.forEach { add(it, root, "$name/${it.name}", name) }
|
||||
}
|
||||
if (legacy) {
|
||||
legacyBattleNet(context).forEach { source ->
|
||||
add(source.file, source.root, source.entry, "battlenet")
|
||||
}
|
||||
} else {
|
||||
battleNetRoots(context).forEach { (prefix, root) ->
|
||||
val scan = walk(root, remaining(maxFiles, out.size))
|
||||
truncated = truncated || scan.truncated
|
||||
scan.files.asSequence().filter { recent(it) && it.name.endsWith(".log", true) }
|
||||
.forEach { file ->
|
||||
val relative = root.toPath().toAbsolutePath().normalize().relativize(file.toPath().toAbsolutePath().normalize()).joinToString("/")
|
||||
add(file, root, "battlenet/$prefix/$relative", "battlenet")
|
||||
}
|
||||
}
|
||||
}
|
||||
val sessionScan = walk(folder, remaining(maxFiles, out.size))
|
||||
truncated = truncated || sessionScan.truncated
|
||||
sessionScan.files.forEach { file ->
|
||||
val relative = file.toPath().toAbsolutePath().normalize()
|
||||
.let { folder.toPath().toAbsolutePath().normalize().relativize(it).joinToString("/") }
|
||||
if (relative == EnhancedDiagnostics.MANIFEST_FILE) return@forEach
|
||||
add(file, folder, "${folder.name}/$relative", "session")
|
||||
}
|
||||
return CollectionResult(out.values.toList(), truncated)
|
||||
}
|
||||
|
||||
private fun remaining(maxFiles: Int, found: Int): Int = (maxFiles - found + 1).coerceAtLeast(0)
|
||||
|
||||
private fun walk(root: File, limit: Int): ScanResult {
|
||||
if (limit <= 0 || !root.isDirectory || Files.isSymbolicLink(root.toPath())) return ScanResult(emptyList(), false)
|
||||
val files = ArrayList<File>(limit)
|
||||
var truncated = false
|
||||
Files.walk(root.toPath()).use { paths ->
|
||||
val iterator = paths.iterator()
|
||||
var visited = 0
|
||||
val visitLimit = (limit * 4).coerceAtLeast(limit).coerceAtMost(MAX_DISCOVERY_VISITS)
|
||||
while (iterator.hasNext() && files.size < limit && visited < visitLimit) {
|
||||
val path = iterator.next()
|
||||
visited++
|
||||
if (path != root.toPath() &&
|
||||
(Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(path))) {
|
||||
files += path.toFile()
|
||||
}
|
||||
}
|
||||
truncated = iterator.hasNext()
|
||||
}
|
||||
return ScanResult(files, truncated)
|
||||
}
|
||||
|
||||
private fun directFiles(root: File, limit: Int): ScanResult {
|
||||
if (limit <= 0 || !root.isDirectory || Files.isSymbolicLink(root.toPath())) return ScanResult(emptyList(), false)
|
||||
val files = ArrayList<File>(limit)
|
||||
var truncated = false
|
||||
Files.newDirectoryStream(root.toPath()).use { entries ->
|
||||
val iterator = entries.iterator()
|
||||
while (iterator.hasNext() && files.size < limit) {
|
||||
val path = iterator.next()
|
||||
if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(path)) {
|
||||
files += path.toFile()
|
||||
}
|
||||
}
|
||||
truncated = iterator.hasNext()
|
||||
}
|
||||
return ScanResult(files, truncated)
|
||||
}
|
||||
|
||||
private fun battleNetRoots(context: Context): List<Pair<String, File>> {
|
||||
val drive = File(BattleNet.prefix(context), "pfx/drive_c")
|
||||
val data = File(drive, "ProgramData/Battle.net")
|
||||
return listOf(
|
||||
"droiddeck" to File(LinuxRuntime.rootDir(context), "root/.cache/droiddeck/battlenet"),
|
||||
"client" to File(drive, "users/steamuser/AppData/Local/Battle.net/Logs"),
|
||||
"agent" to File(data, "Agent"),
|
||||
"setup" to File(data, "Setup"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun legacyBattleNet(context: Context): List<LegacySource> {
|
||||
val drive = File(BattleNet.prefix(context), "pfx/drive_c")
|
||||
val data = File(drive, "ProgramData/Battle.net")
|
||||
val agent = File(data, "Agent")
|
||||
val setup = File(data, "Setup")
|
||||
return legacyNewest(
|
||||
"droiddeck",
|
||||
listOf(File(LinuxRuntime.rootDir(context), "root/.cache/droiddeck/battlenet")),
|
||||
2,
|
||||
setOf("battlenet.log", "spawned.log"),
|
||||
) + legacyNewest(
|
||||
"client", listOf(File(drive, "users/steamuser/AppData/Local/Battle.net/Logs")), 6,
|
||||
) + legacyNewest(
|
||||
"agent",
|
||||
listOf(File(agent, "Logs")) + childDirectories(agent, "Agent.").map { File(it, "Logs") },
|
||||
10,
|
||||
byDir = true,
|
||||
) + legacyNewest(
|
||||
"setup", childDirectories(setup, "").map { File(it, "Logs") }, 3, byDir = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun legacyNewest(
|
||||
folder: String,
|
||||
roots: List<File>,
|
||||
count: Int,
|
||||
names: Set<String>? = null,
|
||||
byDir: Boolean = false,
|
||||
): List<LegacySource> = roots.flatMap { root ->
|
||||
directFiles(root, MAX_BATTLENET_SCAN).files.asSequence()
|
||||
.filter { recent(it) && it.name.endsWith(".log") && it.length() <= LEGACY_LOG_MAX_BYTES }
|
||||
.filter { names == null || it.name in names }
|
||||
.map { it to root }
|
||||
.toList()
|
||||
}.sortedByDescending { it.first.lastModified() }.take(count).map { (file, root) ->
|
||||
val relative = if (byDir) "$folder/${file.parentFile?.parentFile?.name}/${file.name}" else "$folder/${file.name}"
|
||||
LegacySource(file, root, "battlenet/$relative")
|
||||
}
|
||||
|
||||
private fun childDirectories(root: File, prefix: String): List<File> {
|
||||
if (!root.isDirectory || Files.isSymbolicLink(root.toPath())) return emptyList()
|
||||
val found = ArrayList<File>()
|
||||
Files.newDirectoryStream(root.toPath()).use { entries ->
|
||||
val iterator = entries.iterator()
|
||||
while (iterator.hasNext() && found.size < MAX_BATTLENET_DIRECTORIES) {
|
||||
val path = iterator.next()
|
||||
if (path.fileName.toString().startsWith(prefix) &&
|
||||
Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(path)) found += path.toFile()
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
private fun recent(file: File): Boolean = file.lastModified() >= System.currentTimeMillis() - RECENT_MILLIS
|
||||
|
||||
private fun anchorFor(context: Context, root: File): File {
|
||||
val path = root.toPath().toAbsolutePath().normalize()
|
||||
for (anchor in listOf(context.filesDir, context.cacheDir)) {
|
||||
if (path.startsWith(anchor.toPath().toAbsolutePath().normalize())) return anchor
|
||||
}
|
||||
return root.parentFile ?: root
|
||||
}
|
||||
|
||||
private fun safeEntry(name: String): String? {
|
||||
if (name.any { it.code < 0x20 || it.code == 0x7f }) return null
|
||||
val normalized = name.replace('\\', '/').trimStart('/')
|
||||
val parts = normalized.split('/')
|
||||
if (normalized.isEmpty() || parts.any { it.isEmpty() || it == "." || it == ".." }) return null
|
||||
return parts.joinToString("/") { safeComponent(it) }
|
||||
}
|
||||
|
||||
private fun safeComponent(value: String): String {
|
||||
if (value in KNOWN_COMPONENTS || KNOWN_COMPONENT_PATTERNS.any { it.matches(value) }) return value
|
||||
val suffix = SAFE_SUFFIX.find(value)?.value.orEmpty().lowercase(Locale.US)
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
|
||||
.take(8).joinToString("") { "%02x".format(Locale.US, it.toInt() and 0xff) }
|
||||
return "entry-$digest$suffix"
|
||||
}
|
||||
|
||||
private fun category(entry: String): Category {
|
||||
val value = entry.lowercase(Locale.US)
|
||||
if (value.startsWith("battlenet/") || value.startsWith("stores/") || value.startsWith("tools/") ||
|
||||
value.contains("droiddeck-esync/") || GAME_LOG.matches(value.substringAfterLast('/')) ||
|
||||
listOf("proton", "wine", "dxvk", "vkd3d", "battle.net", "game-debug").any(value::contains)) return Category.GAMES
|
||||
if (listOf("wayland", "gamescope", "xwayland", "vulkan", "gpu", "mesa", "egl", "glx", "drm", "compositor", "frame-trace").any(value::contains)) return Category.GRAPHICS
|
||||
return Category.SESSION
|
||||
}
|
||||
|
||||
private val GAME_LOG = Regex("steam-[0-9]+\\.(log|txt)")
|
||||
private val KNOWN_COMPONENTS = setOf(
|
||||
"steam", "graphics", "games", "battlenet", "client", "agent", "setup", "stores", "tools",
|
||||
"droiddeck", "droiddeck-esync", "session.log", "app.log", "wayland.log", "audio.log",
|
||||
"device.txt", "network.txt", "events.jsonl", "memory.csv", "capture.json", "crash.log",
|
||||
"frame-trace.log", "process-trace.log", "game-lifecycle.log", "launches.log", "tools.tsv", "wanted.tsv", "prefixes.json",
|
||||
"battlenet.log", "spawned.log", "flatpak-install.log", "cef_log.txt", "cef_log.previous.txt",
|
||||
"console_log.txt", "webhelper_js.txt", "controller_support.txt", "systemperfmanager.txt",
|
||||
)
|
||||
private val KNOWN_COMPONENT_PATTERNS = listOf(
|
||||
Regex("[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{2,}-[a-z0-9-]{1,24}"),
|
||||
Regex("steam-[0-9]+\\.(?:log|txt)"),
|
||||
Regex("(?:battle\\.net-[0-9]+|battle\\.net-setup-[0-9]+|Agent-[0-9]+)\\.log"),
|
||||
Regex("(?:Agent\\.[0-9]+|bna_[0-9]+)"),
|
||||
Regex("stores-[0-9]{4}-[0-9]{2}-[0-9]{2}\\.log"),
|
||||
Regex("entry-[0-9a-f]{16}(?:\\.[a-z0-9]{1,8})?"),
|
||||
)
|
||||
private val SAFE_SUFFIX = Regex("\\.[A-Za-z0-9]{1,8}$")
|
||||
private val CRITICAL_SESSION_FILES = listOf(
|
||||
"session.log", "app.log", "wayland.log", "audio.log", "device.txt", "network.txt",
|
||||
"events.jsonl", "memory.csv", "capture.json", "crash.log",
|
||||
)
|
||||
private const val RECENT_MILLIS = 7L * 24 * 60 * 60 * 1000
|
||||
private const val TRACE_SCAN_BYTES = 16L * 1024 * 1024
|
||||
private const val LEGACY_LOG_MAX_BYTES = 8L * 1024 * 1024
|
||||
private const val MAX_BATTLENET_SCAN = 64
|
||||
private const val MAX_BATTLENET_DIRECTORIES = 64
|
||||
private const val MAX_MANIFEST_RECORDS = 512
|
||||
private const val MAX_DISCOVERY_VISITS = 4096
|
||||
}
|
||||
@@ -117,8 +117,7 @@ object GameEnvironmentStore {
|
||||
if (gameDxvk.length() > 0) json.put(DXVK_CONFIG_GAMES, gameDxvk)
|
||||
json.put(
|
||||
GAME_DEBUG_LOGS,
|
||||
gameDebugLogsOverride ?: (SessionPrefs.logsEnabled(context) &&
|
||||
SessionPrefs.gameDebugLogsEnabled(context) && SessionPaths.currentSessionLogsRetained(context)),
|
||||
gameDebugLogsOverride ?: SessionPrefs.gameDebugLogsEffective(context),
|
||||
)
|
||||
write(File(LinuxRuntime.rootDir(context), GUEST_FILE), json.toString())
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ object SessionFiles {
|
||||
WinComponents.publish(context)
|
||||
val files = arrayOf(
|
||||
"usr/local/bin/droiddeck-game-env" to "usr/local/bin/droiddeck-game-env",
|
||||
"usr/local/bin/droiddeck-diagnostic-inventory" to "usr/local/bin/droiddeck-diagnostic-inventory",
|
||||
"usr/local/bin/droiddeck-wincomponents" to "usr/local/bin/droiddeck-wincomponents",
|
||||
"usr/local/bin/droiddeck-msi-install" to "usr/local/bin/droiddeck-msi-install",
|
||||
"usr/local/bin/droiddeck-esync" to "usr/local/bin/droiddeck-esync",
|
||||
|
||||
@@ -11,12 +11,8 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.FileProvider
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.LogRedactor
|
||||
import com.droiddeck.launcher.runtime.BattleNet
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import java.util.ArrayList
|
||||
|
||||
object SessionLogShare {
|
||||
data class Details(val sizeBytes: Long, val modifiedAt: Long)
|
||||
@@ -62,84 +58,30 @@ object SessionLogShare {
|
||||
return knownSession && folder.isDirectory && folder.deleteRecursively()
|
||||
}
|
||||
|
||||
fun prepareEnhanced(context: Context, folder: () -> File?, done: (List<File>) -> Unit) {
|
||||
if (progress != null) return
|
||||
progress = 0f
|
||||
Thread({
|
||||
val zips = runCatching {
|
||||
folder()?.let { dir ->
|
||||
EnhancedSessionLogShare.create(context, dir, onProgress = { p ->
|
||||
main.post { if (progress != null) progress = p }
|
||||
})
|
||||
}.orEmpty()
|
||||
}.onFailure { Log.w("SessionLogShare", "could not package enhanced diagnostics", it) }
|
||||
.getOrDefault(emptyList())
|
||||
main.post { progress = null; done(zips) }
|
||||
}, "share-diagnostics").start()
|
||||
}
|
||||
|
||||
|
||||
fun zipFolder(context: Context, folder: File, onProgress: ((Float) -> Unit)? = null): File? {
|
||||
if (!folder.isDirectory) return null
|
||||
val files = folder.walkTopDown().filter { it.isFile }
|
||||
.filterNot { it.parentFile?.name == "steam" && it.length() > STEAM_LOG_MAX_BYTES }.toList()
|
||||
if (files.isEmpty()) return null
|
||||
val out = shareDir(context).apply { deleteRecursively(); mkdirs() }
|
||||
val zip = File(out, "DroidDeck-${folder.name}.zip")
|
||||
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
|
||||
val live = liveSteamLogs(context, folder)
|
||||
val logs = LinuxRuntime.logDir(context)
|
||||
val stores = recent(File(logs, STORES_DIR))
|
||||
val tools = recent(File(logs, SessionPaths.TOOLS_DIR))
|
||||
val battleNet = battleNetLogs(context)
|
||||
val total = (files + live + stores + tools + battleNet.map { it.second }).sumOf { it.length() }.coerceAtLeast(1L)
|
||||
var done = 0L
|
||||
var shown = -1
|
||||
fun advance(f: File) {
|
||||
done += f.length()
|
||||
val percent = (done * 100 / total).toInt()
|
||||
if (percent != shown) { shown = percent; onProgress?.invoke(percent / 100f) }
|
||||
}
|
||||
ZipOutputStream(zip.outputStream().buffered()).use { z ->
|
||||
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).invariantSeparatorsPath, f); advance(f) }
|
||||
live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f); advance(f) }
|
||||
stores.forEach { f -> addEntry(z, "$STORES_DIR/" + f.name, f); advance(f) }
|
||||
tools.forEach { f -> addEntry(z, SessionPaths.TOOLS_DIR + "/" + f.name, f); advance(f) }
|
||||
battleNet.forEach { (name, f) -> addEntry(z, "$BATTLENET_DIR/$name", f); advance(f) }
|
||||
}
|
||||
return zip
|
||||
return EnhancedSessionLogShare.createLegacy(context, folder, onProgress)
|
||||
}
|
||||
|
||||
private fun addEntry(z: ZipOutputStream, name: String, f: File) {
|
||||
z.putNextEntry(ZipEntry(name))
|
||||
val w = z.bufferedWriter()
|
||||
LogRedactor.scrubForShare(f, w)
|
||||
w.flush()
|
||||
z.closeEntry()
|
||||
}
|
||||
|
||||
private fun liveSteamLogs(context: Context, folder: File): List<File> {
|
||||
if (folder != SessionPaths.current() || File(folder, "steam").exists()) return emptyList()
|
||||
val logs = File(SessionPrefs.activeSteamRoot(context), "logs")
|
||||
return logs.listFiles()?.filter { it.isFile && it.length() <= STEAM_LOG_MAX_BYTES }.orEmpty()
|
||||
}
|
||||
|
||||
private const val STEAM_LOG_MAX_BYTES = 8L * 1024 * 1024
|
||||
|
||||
const val STORES_DIR = "stores"
|
||||
private const val RECENT_DAYS = 7
|
||||
|
||||
private fun shareDir(context: Context): File = File(context.cacheDir, "share")
|
||||
|
||||
private const val BATTLENET_DIR = "battlenet"
|
||||
|
||||
private fun battleNetLogs(context: Context): List<Pair<String, File>> {
|
||||
val drive = File(BattleNet.prefix(context), "pfx/drive_c")
|
||||
val data = File(drive, "ProgramData/Battle.net")
|
||||
fun logsUnder(dir: File, prefix: String) = dir.listFiles { f -> f.isDirectory && f.name.startsWith(prefix) }.orEmpty().map { File(it, "Logs") }
|
||||
return newest("droiddeck", listOf(File(LinuxRuntime.rootDir(context), "root/.cache/droiddeck/battlenet")), 2, setOf("battlenet.log", "spawned.log")) +
|
||||
newest("client", listOf(File(drive, "users/steamuser/AppData/Local/Battle.net/Logs")), 6) +
|
||||
newest("agent", listOf(File(data, "Agent/Logs")) + logsUnder(File(data, "Agent"), "Agent."), 10, byDir = true) +
|
||||
newest("setup", logsUnder(File(data, "Setup"), ""), 3, byDir = true)
|
||||
}
|
||||
|
||||
private fun newest(folder: String, dirs: List<File>, count: Int, names: Set<String>? = null, byDir: Boolean = false): List<Pair<String, File>> {
|
||||
val since = System.currentTimeMillis() - RECENT_DAYS * 86_400_000L
|
||||
return dirs.flatMap { dir ->
|
||||
dir.listFiles { f -> f.isFile && f.name.endsWith(".log") && (names == null || f.name in names) && f.lastModified() >= since && f.length() <= STEAM_LOG_MAX_BYTES }.orEmpty().toList()
|
||||
}.sortedByDescending { it.lastModified() }.take(count).map { f ->
|
||||
(if (byDir) "$folder/${f.parentFile?.parentFile?.name}/${f.name}" else "$folder/${f.name}") to f
|
||||
}
|
||||
}
|
||||
|
||||
private fun recent(dir: File): List<File> {
|
||||
val since = System.currentTimeMillis() - RECENT_DAYS * 86_400_000L
|
||||
return dir.listFiles { f -> f.isFile && f.lastModified() >= since }?.sortedBy { it.name }.orEmpty()
|
||||
}
|
||||
internal fun shareDir(context: Context): File = File(context.cacheDir, "share")
|
||||
|
||||
fun clear(context: Context) {
|
||||
shareDir(context).deleteRecursively()
|
||||
@@ -156,4 +98,19 @@ object SessionLogShare {
|
||||
send.clipData = ClipData.newRawUri(zip.name, uri)
|
||||
return Intent.createChooser(send, context.getString(R.string.logshare_chooser)).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
fun shareEnhancedIntent(context: Context, zips: List<File>): Intent {
|
||||
require(zips.isNotEmpty())
|
||||
val uris = ArrayList(zips.map { FileProvider.getUriForFile(context, context.packageName + ".logs", it) })
|
||||
val send = Intent(Intent.ACTION_SEND_MULTIPLE)
|
||||
.setType("application/zip")
|
||||
.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
|
||||
.putExtra(Intent.EXTRA_SUBJECT, zips.first().name.substringBeforeLast('-'))
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
send.clipData = ClipData.newUri(context.contentResolver, zips.first().name, uris.first()).also { clip ->
|
||||
for (i in 1 until uris.size) clip.addItem(ClipData.Item(uris[i]))
|
||||
}
|
||||
return Intent.createChooser(send, context.getString(R.string.logshare_chooser))
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,8 @@ object SessionPaths {
|
||||
@Synchronized
|
||||
fun beginOrCurrent(context: Context, label: String? = null): File {
|
||||
dir?.let { if (it.isDirectory) return it }
|
||||
val parent = if (SessionPrefs.logsEnabled(context)) SessionFiles.logDirectory(context)
|
||||
val enhanced = EnhancedDiagnostics.isArmed(context)
|
||||
val parent = if (SessionPrefs.logsEnabled(context) || enhanced) SessionFiles.logDirectory(context)
|
||||
else File(context.cacheDir, "session-logs")
|
||||
val day = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
|
||||
val last = parent.listFiles()?.mapNotNull { f ->
|
||||
@@ -36,9 +37,13 @@ object SessionPaths {
|
||||
var number = last + 1
|
||||
var made = File(parent, "$day-${"%02d".format(Locale.US, number)}-$what")
|
||||
while (made.exists()) made = File(parent, "$day-${"%02d".format(Locale.US, ++number)}-$what")
|
||||
EnhancedDiagnostics.clearRecordFor(context, made)
|
||||
if (!made.isDirectory && !made.mkdirs()) Log.e(TAG, "could not create $made")
|
||||
dir = made
|
||||
Log.i(TAG, "session logs: $made" + if (SessionPrefs.logsEnabled(context)) "" else " (logs off: discarded at the end)")
|
||||
if (enhanced && !EnhancedDiagnostics.consumeInto(context, made)) {
|
||||
Log.e(TAG, "could not activate enhanced diagnostics for $made")
|
||||
}
|
||||
Log.i(TAG, "session logs: $made" + if (parent == SessionFiles.logDirectory(context)) "" else " (logs off: discarded at the end)")
|
||||
return made
|
||||
}
|
||||
|
||||
|
||||
@@ -824,7 +824,7 @@ object SessionPrefs {
|
||||
key = "logs",
|
||||
oldValue = logsEnabled(context),
|
||||
newValue = on,
|
||||
newEffective = on && gameDebugLogsEnabled(context) && SessionPaths.currentSessionLogsRetained(context),
|
||||
newEffective = gameDebugLogsEffective(context, on, gameDebugLogsEnabled(context)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -837,7 +837,7 @@ object SessionPrefs {
|
||||
key = "gameDebugLogsEnabled",
|
||||
oldValue = gameDebugLogsEnabled(context),
|
||||
newValue = on,
|
||||
newEffective = logsEnabled(context) && on && SessionPaths.currentSessionLogsRetained(context),
|
||||
newEffective = gameDebugLogsEffective(context, logsEnabled(context), on),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -850,8 +850,7 @@ object SessionPrefs {
|
||||
): Boolean {
|
||||
if (oldValue == newValue) return true
|
||||
val saved = prefs(context)
|
||||
val oldEffective = logsEnabled(context) && gameDebugLogsEnabled(context) &&
|
||||
SessionPaths.currentSessionLogsRetained(context)
|
||||
val oldEffective = gameDebugLogsEffective(context)
|
||||
fun publish(value: Boolean) = GameEnvironmentStore.publish(context, gameDebugLogsOverride = value)
|
||||
if (oldEffective == newEffective) {
|
||||
if (saved.edit().putBoolean(key, newValue).commit()) return true
|
||||
@@ -887,6 +886,13 @@ object SessionPrefs {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun gameDebugLogsEffective(
|
||||
context: Context,
|
||||
logs: Boolean = logsEnabled(context),
|
||||
gameLogs: Boolean = gameDebugLogsEnabled(context),
|
||||
): Boolean = EnhancedDiagnostics.isCurrentSessionActive(context) ||
|
||||
(logs && gameLogs && SessionPaths.currentSessionLogsRetained(context))
|
||||
|
||||
fun battleNetNoSteamSet(context: Context): Boolean = prefs(context).getBoolean("battleNetNoSteamSet", false)
|
||||
fun setBattleNetNoSteamSet(context: Context) { prefs(context).edit().putBoolean("battleNetNoSteamSet", true).apply() }
|
||||
|
||||
|
||||
@@ -540,6 +540,7 @@ class SessionService : Service() {
|
||||
guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz))
|
||||
guest.add("BL_LOG=" + sessionLog.path)
|
||||
guest.add("BL_DEBUG_DIR=" + sessionDir.path)
|
||||
if (EnhancedDiagnostics.isActive(this, sessionDir)) guest.add("DROIDDECK_ENHANCED_DIAGNOSTICS=1")
|
||||
|
||||
val fakeInputDir = File(sessionRoot, "dev/input").apply { mkdirs() }
|
||||
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
|
||||
@@ -768,6 +769,7 @@ class SessionService : Service() {
|
||||
SessionState.sessionId = sessionDir.name
|
||||
SessionState.eventsFile = File(sessionDir, "events.jsonl")
|
||||
SessionEvents.record("session.logs_ready", mapOf("logDir" to sessionDir.path))
|
||||
if (EnhancedDiagnostics.isActive(this, sessionDir)) SessionEvents.record("diagnostics.enhanced", mapOf("schema" to 1))
|
||||
DeviceReport.write(this, File(sessionDir, "device.txt"), SessionState.mode)
|
||||
NetworkReport.write(this, File(sessionDir, "network.txt"))
|
||||
SessionLogCapture.start(File(sessionDir, "app.log"))
|
||||
|
||||
@@ -22,7 +22,7 @@ object SettingsReset {
|
||||
"tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "gamescopeRealtime", "ramCapMb", "vramCapMb", "zramClient",
|
||||
"directAudio", "mic", "wifiDiscovery", "clientCpusOverride", "clientCpus", "gameCpus",
|
||||
"noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath", "cursorComposite",
|
||||
"guestHostname", "fexPreset", "forceSsbs", "logs", "gameDebugLogsEnabled", "storageDiagnostics",
|
||||
"guestHostname", "fexPreset", "forceSsbs", "logs", "gameDebugLogsEnabled", "enhancedDiagnosticsNext", "storageDiagnostics",
|
||||
"upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias",
|
||||
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", "bootMovieVolume", "bootMovieSeconds",
|
||||
"bootMovieApplyNext", "bootMovieSteamSeen",
|
||||
|
||||
@@ -102,6 +102,7 @@ class FrontEndState(
|
||||
val frameGenLabel: String,
|
||||
val romsDir: String?,
|
||||
val logsEnabled: Boolean,
|
||||
val enhancedDiagnosticsArmed: Boolean = false,
|
||||
val gameDebugLogsEnabled: Boolean = false,
|
||||
val gameDebugLogsNeedsRestart: Boolean = false,
|
||||
val vacDiagnostics: Boolean = false,
|
||||
@@ -227,6 +228,7 @@ class FrontEndActions(
|
||||
val onFiles: () -> Unit,
|
||||
val onBrowseFiles: (File) -> Unit = {},
|
||||
val onLogs: () -> Unit,
|
||||
val onEnhancedDiagnostics: () -> Unit = {},
|
||||
val onGameDebugLogs: () -> Unit = {},
|
||||
val onVacDiagnostics: () -> Unit = {},
|
||||
val onAgentCommands: () -> Unit = {},
|
||||
|
||||
@@ -546,6 +546,10 @@ internal fun SetupPanel(
|
||||
5 -> {
|
||||
SettingsGroup(stringResource(R.string.setup_tab_diagnostics)) {
|
||||
ToggleRow(host, "logs", stringResource(R.string.setup_logs), stringResource(R.string.setup_logs_hint), s.logsEnabled) { a.onLogs() }
|
||||
ToggleRow(
|
||||
host, "enhanced-diagnostics", stringResource(R.string.setup_enhanced_diagnostics),
|
||||
stringResource(R.string.setup_enhanced_diagnostics_hint), s.enhancedDiagnosticsArmed,
|
||||
) { a.onEnhancedDiagnostics() }
|
||||
ToggleRow(
|
||||
host, "game-debug-logs", stringResource(R.string.setup_game_debug_logs),
|
||||
stringResource(if (s.gameDebugLogsNeedsRestart) R.string.setup_game_debug_logs_restart else R.string.setup_game_debug_logs_hint),
|
||||
|
||||
@@ -915,6 +915,13 @@
|
||||
<string name="setup_session">Session</string>
|
||||
<string name="setup_logs">Session logs</string>
|
||||
<string name="setup_logs_hint">Saved after each session</string>
|
||||
<string name="setup_enhanced_diagnostics">Enhanced diagnostics for next session</string>
|
||||
<string name="setup_enhanced_diagnostics_hint">Captures detailed logs for one session. May reduce performance and use more storage. Turns itself off when the session starts; review logs before sharing.</string>
|
||||
<string name="diagnostics_share_title">Share diagnostic bundles</string>
|
||||
<string name="diagnostics_share_session">Session and device</string>
|
||||
<string name="diagnostics_share_graphics">Graphics</string>
|
||||
<string name="diagnostics_share_games">Games and compatibility</string>
|
||||
<string name="diagnostics_share_all">All bundles</string>
|
||||
<string name="setup_game_debug_logs">Game debug logs</string>
|
||||
<string name="setup_game_debug_logs_hint">Include Proton, Wine and graphics diagnostics on the next game launch. Can reduce performance and use more storage.</string>
|
||||
<string name="setup_game_debug_logs_restart">Restart this session to save game debug logs</string>
|
||||
|
||||
@@ -316,6 +316,60 @@ class LogRedactorTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun oversizedAccountMetadataIsRejectedWithoutForgettingKnownNames() {
|
||||
val users = tmp.newFile("known-loginusers.vdf")
|
||||
val oversized = tmp.newFile("oversized-loginusers.vdf")
|
||||
users.writeText("\"AccountName\" \"previous_player\"")
|
||||
java.io.RandomAccessFile(oversized, "rw").use {
|
||||
it.setLength(LogRedactor.MAX_LEARN_INPUT_BYTES.toLong() + 1)
|
||||
}
|
||||
try {
|
||||
assertTrue(LogRedactor.learnAccounts(users))
|
||||
assertFalse(LogRedactor.learnAccounts(oversized))
|
||||
assertEquals("<redacted:account>", LogRedactor.redact("previous_player"))
|
||||
} finally {
|
||||
LogRedactor.learnAccounts()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun repeatedIncompleteAccountLearningKeepsOnlyABoundedRecentSet() {
|
||||
val users = tmp.newFile("changing-loginusers.vdf")
|
||||
val oversized = tmp.newFile("unreadable-loginusers.vdf")
|
||||
java.io.RandomAccessFile(oversized, "rw").use {
|
||||
it.setLength(LogRedactor.MAX_LEARN_INPUT_BYTES.toLong() + 1)
|
||||
}
|
||||
try {
|
||||
LogRedactor.learnAccounts()
|
||||
repeat(3) { batch ->
|
||||
users.writeText((0 until 1024).joinToString("\n") {
|
||||
"\"AccountName\" \"round${batch}player${it.toString().padStart(4, '0')}\""
|
||||
})
|
||||
assertFalse(LogRedactor.learnAccounts(users, oversized))
|
||||
}
|
||||
assertEquals("<redacted:account>", LogRedactor.redact("round2player0000"))
|
||||
assertEquals("<redacted:account>", LogRedactor.redact("round1player0000"))
|
||||
assertEquals("round0player0000", LogRedactor.redact("round0player0000"))
|
||||
} finally {
|
||||
LogRedactor.learnAccounts()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun oversizedAddressMetadataIsRejectedWithoutForgettingKnownAddresses() {
|
||||
val link = tmp.newFile("known-network")
|
||||
val oversized = tmp.newFile("oversized-network")
|
||||
link.writeText("addr 203.0.113.40 24\n")
|
||||
java.io.RandomAccessFile(oversized, "rw").use {
|
||||
it.setLength(LogRedactor.MAX_LEARN_INPUT_BYTES.toLong() + 1)
|
||||
}
|
||||
try {
|
||||
assertTrue(LogRedactor.learnOwnAddresses(link))
|
||||
assertFalse(LogRedactor.learnOwnAddresses(oversized))
|
||||
assertEquals("<redacted:ip>", LogRedactor.redact("203.0.113.40"))
|
||||
} finally {
|
||||
LogRedactor.learnOwnAddresses(tmp.root.resolve("missing-network"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun manyLearnedAddressesKeepOtherAddresses() {
|
||||
val link = tmp.newFile("droiddeck-net")
|
||||
link.writeText((1..40).joinToString("\n") { "addr 203.0.113.$it 24" })
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class EnhancedDiagnosticsTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var guest: File
|
||||
|
||||
@Before fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
SessionPaths.take()?.let { SessionPaths.release(context, it) }
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit()
|
||||
.remove("logs")
|
||||
.remove("gameDebugLogsEnabled")
|
||||
.remove("enhancedDiagnosticsNext")
|
||||
.remove("enhancedDiagnosticsActiveSessions")
|
||||
.commit()
|
||||
guest = File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/game-environment.json")
|
||||
}
|
||||
|
||||
@After fun tearDown() {
|
||||
SessionPaths.take()?.let { folder ->
|
||||
SessionPaths.release(context, folder)
|
||||
folder.deleteRecursively()
|
||||
}
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit()
|
||||
.remove("logs")
|
||||
.remove("gameDebugLogsEnabled")
|
||||
.remove("enhancedDiagnosticsNext")
|
||||
.remove("enhancedDiagnosticsActiveSessions")
|
||||
.commit()
|
||||
GameEnvironmentStore.publish(context)
|
||||
}
|
||||
|
||||
@Test fun armedSessionIsRetainedAndConsumesTheOneShotPreference() {
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().putBoolean("logs", false).commit()
|
||||
assertTrue(EnhancedDiagnostics.setArmed(context, true))
|
||||
|
||||
val folder = SessionPaths.beginOrCurrent(context, "enhanced-test")
|
||||
|
||||
assertEquals(SessionFiles.logDirectory(context), folder.parentFile)
|
||||
assertFalse(EnhancedDiagnostics.isArmed(context))
|
||||
assertTrue(EnhancedDiagnostics.isActive(context, folder))
|
||||
val manifest = JSONObject(File(folder, EnhancedDiagnostics.MANIFEST_FILE).readText())
|
||||
assertEquals(1, manifest.getInt("schema"))
|
||||
assertEquals("enhanced", manifest.getString("profile"))
|
||||
assertEquals(folder.name, manifest.getString("sessionId"))
|
||||
assertTrue(manifest.getBoolean("exportBundlesBounded"))
|
||||
assertFalse(manifest.getBoolean("rawCaptureByteBounded"))
|
||||
}
|
||||
|
||||
@Test fun activeProfileForcesGameDiagnosticsWithoutChangingUserPreferences() {
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit()
|
||||
.putBoolean("logs", false)
|
||||
.putBoolean("gameDebugLogsEnabled", false)
|
||||
.commit()
|
||||
assertTrue(EnhancedDiagnostics.setArmed(context, true))
|
||||
SessionPaths.beginOrCurrent(context, "enhanced-game-logs")
|
||||
|
||||
GameEnvironmentStore.publish(context)
|
||||
|
||||
assertTrue(JSONObject(guest.readText()).getBoolean(GameEnvironmentStore.GAME_DEBUG_LOGS))
|
||||
assertFalse(SessionPrefs.logsEnabled(context))
|
||||
assertFalse(SessionPrefs.gameDebugLogsEnabled(context))
|
||||
}
|
||||
|
||||
@Test fun cancellingTheOneShotLeavesOrdinaryLogRetentionUnchanged() {
|
||||
context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().putBoolean("logs", false).commit()
|
||||
assertTrue(EnhancedDiagnostics.setArmed(context, true))
|
||||
assertTrue(EnhancedDiagnostics.setArmed(context, false))
|
||||
|
||||
val folder = SessionPaths.beginOrCurrent(context, "ordinary-test")
|
||||
|
||||
assertEquals(File(context.cacheDir, "session-logs"), folder.parentFile)
|
||||
assertFalse(EnhancedDiagnostics.isActive(context, folder))
|
||||
}
|
||||
|
||||
@Test fun guestCannotForgeEnhancedDiagnosticsByWritingAMarker() {
|
||||
val folder = SessionPaths.beginOrCurrent(context, "forged-marker")
|
||||
File(folder, EnhancedDiagnostics.MANIFEST_FILE).writeText(
|
||||
"""{"schema":1,"profile":"enhanced"}""",
|
||||
)
|
||||
|
||||
assertFalse(EnhancedDiagnostics.isActive(context, folder))
|
||||
assertNull(EnhancedDiagnostics.manifest(context, folder))
|
||||
}
|
||||
|
||||
@Test fun trustedStateSurvivesGuestMarkerDeletionAndSessionRecovery() {
|
||||
assertTrue(EnhancedDiagnostics.setArmed(context, true))
|
||||
val folder = SessionPaths.beginOrCurrent(context, "deleted-marker")
|
||||
assertTrue(File(folder, EnhancedDiagnostics.MANIFEST_FILE).delete())
|
||||
|
||||
assertTrue(EnhancedDiagnostics.isActive(context, File(folder.path)))
|
||||
assertEquals(folder.name, EnhancedDiagnostics.manifest(context, folder)?.getString("sessionId"))
|
||||
assertEquals(folder, SessionPaths.take())
|
||||
assertTrue(EnhancedDiagnostics.isActive(context, folder))
|
||||
}
|
||||
|
||||
@Test fun informationalMarkerFailureDoesNotRearmTheNextSession() {
|
||||
val parent = SessionFiles.logDirectory(context).apply { mkdirs() }
|
||||
val folder = File(parent, "2026-11-04-99-marker-failure").apply { mkdirs() }
|
||||
File(folder, EnhancedDiagnostics.MANIFEST_FILE).mkdirs()
|
||||
assertTrue(EnhancedDiagnostics.setArmed(context, true))
|
||||
|
||||
assertTrue(EnhancedDiagnostics.consumeInto(context, folder))
|
||||
|
||||
assertTrue(EnhancedDiagnostics.isActive(context, folder))
|
||||
assertFalse(EnhancedDiagnostics.isArmed(context))
|
||||
folder.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test fun aReusedSessionPathDoesNotInheritTrustedState() {
|
||||
assertTrue(EnhancedDiagnostics.setArmed(context, true))
|
||||
val original = SessionPaths.beginOrCurrent(context, "reused-path")
|
||||
assertTrue(EnhancedDiagnostics.isActive(context, original))
|
||||
assertEquals(original, SessionPaths.take())
|
||||
original.deleteRecursively()
|
||||
|
||||
val replacement = SessionPaths.beginOrCurrent(context, "reused-path")
|
||||
|
||||
assertEquals(original.path, replacement.path)
|
||||
assertFalse(EnhancedDiagnostics.isActive(context, replacement))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Intent
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeFalse
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.file.Files
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class EnhancedSessionLogShareTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
private val app get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
@Test fun diagnosticsAreSplitIntoManifestedArchivesWithOneSessionIdentity() {
|
||||
val session = tmp.newFolder("2026-11-04-01-steam")
|
||||
session.resolve("session.log").writeText("startup ok\n")
|
||||
session.resolve("wayland.log").writeText("frame import ok\n")
|
||||
session.resolve("steam-42.log").writeText("dxvk: present ok\n")
|
||||
|
||||
val zips = EnhancedSessionLogShare.create(app, session)
|
||||
|
||||
assertEquals(
|
||||
setOf("session", "graphics", "games"),
|
||||
zips.map { it.nameWithoutExtension.substringAfterLast('-') }.toSet(),
|
||||
)
|
||||
val expected = mapOf(
|
||||
"session" to "${session.name}/session.log",
|
||||
"graphics" to "${session.name}/wayland.log",
|
||||
"games" to "${session.name}/steam-42.log",
|
||||
)
|
||||
zips.forEach { zip ->
|
||||
val category = zip.nameWithoutExtension.substringAfterLast('-')
|
||||
ZipFile(zip).use { archive ->
|
||||
assertTrue(archive.getEntry(expected.getValue(category)) != null)
|
||||
val manifest = JSONObject(archive.getInputStream(archive.getEntry("diagnostics-manifest.json"))
|
||||
.bufferedReader().readText())
|
||||
assertEquals(1, manifest.getInt("schema"))
|
||||
assertEquals(session.name, manifest.getString("sessionId"))
|
||||
assertEquals(category, manifest.getString("category"))
|
||||
assertEquals("captured", manifest.getJSONArray("entries").getJSONObject(0).getString("status"))
|
||||
}
|
||||
}
|
||||
SessionLogShare.clear(app)
|
||||
}
|
||||
|
||||
@Test fun oversizedFilesKeepBoundedBeginningAndTailAndDeclareTruncation() {
|
||||
val session = tmp.newFolder("2026-11-04-02-game")
|
||||
session.resolve("steam-7.log").writeText("begin\n" + "middle\n".repeat(100) + "tail\n")
|
||||
val limits = EnhancedShareLimits(maxFilesPerArchive = 8, maxBytesPerFile = 96, maxBytesTotal = 512)
|
||||
|
||||
val games = EnhancedSessionLogShare.create(app, session, limits = limits)
|
||||
.single { it.name.endsWith("-games.zip") }
|
||||
|
||||
ZipFile(games).use { archive ->
|
||||
val text = archive.getInputStream(archive.getEntry("${session.name}/steam-7.log")).bufferedReader().readText()
|
||||
assertTrue(text.startsWith("begin"))
|
||||
assertTrue(text.contains("diagnostic file truncated"))
|
||||
assertTrue(text.endsWith("tail\n"))
|
||||
val manifest = JSONObject(archive.getInputStream(archive.getEntry("diagnostics-manifest.json"))
|
||||
.bufferedReader().readText())
|
||||
assertEquals("truncated", manifest.getJSONArray("entries").getJSONObject(0).getString("status"))
|
||||
assertTrue(manifest.getJSONArray("entries").getJSONObject(0).getLong("capturedBytes") <= 96)
|
||||
}
|
||||
SessionLogShare.clear(app)
|
||||
}
|
||||
|
||||
@Test fun symbolicLinksAreNotReadAndTheirOmissionIsRecorded() {
|
||||
assumeFalse(System.getProperty("os.name").startsWith("Windows"))
|
||||
val session = tmp.newFolder("2026-11-04-03-steam")
|
||||
session.resolve("session.log").writeText("safe\n")
|
||||
val outside = tmp.newFile("private.log").apply { writeText("do-not-share\n") }
|
||||
val link = session.toPath().resolve("wayland.log")
|
||||
runCatching { Files.createSymbolicLink(link, outside.toPath()) }.getOrElse { return }
|
||||
|
||||
val graphics = EnhancedSessionLogShare.create(app, session).single { it.name.endsWith("-graphics.zip") }
|
||||
|
||||
ZipFile(graphics).use { archive ->
|
||||
assertFalse(archive.entries().toList().any { it.name.endsWith("wayland.log") })
|
||||
val manifest = JSONObject(archive.getInputStream(archive.getEntry("diagnostics-manifest.json"))
|
||||
.bufferedReader().readText())
|
||||
val entries = manifest.getJSONArray("entries")
|
||||
assertTrue((0 until entries.length()).map { entries.getJSONObject(it) }
|
||||
.any { it.optString("reason") == "unsafe-or-unavailable-source" })
|
||||
}
|
||||
SessionLogShare.clear(app)
|
||||
}
|
||||
|
||||
@Test fun enhancedShareUsesOneMultipleAttachmentIntent() {
|
||||
val session = tmp.newFolder("2026-11-04-04-steam")
|
||||
session.resolve("session.log").writeText("safe\n")
|
||||
val zips = EnhancedSessionLogShare.create(app, session)
|
||||
|
||||
val chooser = SessionLogShare.shareEnhancedIntent(app, zips)
|
||||
val send = chooser.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)!!
|
||||
|
||||
assertEquals(Intent.ACTION_SEND_MULTIPLE, send.action)
|
||||
assertEquals("application/zip", send.type)
|
||||
assertEquals(3, send.getParcelableArrayListExtra<android.net.Uri>(Intent.EXTRA_STREAM)!!.size)
|
||||
assertEquals(3, send.clipData!!.itemCount)
|
||||
SessionLogShare.clear(app)
|
||||
}
|
||||
|
||||
@Test fun graphicsArchiveIncludesOnlyBoundedFrameTraceSummariesFromTheSessionLog() {
|
||||
val session = tmp.newFolder("2026-11-04-05-steam")
|
||||
session.resolve("session.log").writeText(
|
||||
"normal startup line\n" +
|
||||
"DROIDDECK_FRAME_TRACE xdamage=4 commits=2\n" +
|
||||
"unrelated account=test-user\n" +
|
||||
"DROIDDECK_FRAME_TRACE xdamage=9 commits=8\n" +
|
||||
"DROIDDECK_PROCESS_TRACE pid=42 gpuLibs=libvulkan.so\n",
|
||||
)
|
||||
|
||||
val graphics = EnhancedSessionLogShare.create(app, session).single { it.name.endsWith("-graphics.zip") }
|
||||
|
||||
ZipFile(graphics).use { archive ->
|
||||
val trace = archive.getInputStream(archive.getEntry("${session.name}/graphics/frame-trace.log"))
|
||||
.bufferedReader().readText()
|
||||
assertEquals(
|
||||
"DROIDDECK_FRAME_TRACE xdamage=4 commits=2\n" +
|
||||
"DROIDDECK_FRAME_TRACE xdamage=9 commits=8\n",
|
||||
trace,
|
||||
)
|
||||
assertFalse(trace.contains("normal startup"))
|
||||
assertFalse(trace.contains("test-user"))
|
||||
val process = archive.getInputStream(archive.getEntry("${session.name}/graphics/process-trace.log"))
|
||||
.bufferedReader().readText()
|
||||
assertEquals("DROIDDECK_PROCESS_TRACE pid=42 gpuLibs=libvulkan.so\n", process)
|
||||
}
|
||||
SessionLogShare.clear(app)
|
||||
}
|
||||
|
||||
@Test fun truncationNeverSharesASecretWhoseLabelWasCutOffAtTheBoundary() {
|
||||
val session = tmp.newFolder("2026-11-04-06-game")
|
||||
val secret = "abcdef1234567890"
|
||||
session.resolve("steam-8.log").writeText(
|
||||
"padding-refresh_token=$secret" + "x".repeat(512) + "\nvisible tail\n",
|
||||
)
|
||||
val limits = EnhancedShareLimits(maxFilesPerArchive = 8, maxBytesPerFile = 128, maxBytesTotal = 512)
|
||||
|
||||
val games = EnhancedSessionLogShare.create(app, session, limits = limits)
|
||||
.single { it.name.endsWith("-games.zip") }
|
||||
|
||||
val shared = ZipFile(games).use { archive ->
|
||||
archive.getInputStream(archive.getEntry("${session.name}/steam-8.log")).bufferedReader().readText()
|
||||
}
|
||||
assertFalse(shared.contains(secret))
|
||||
assertTrue(shared.contains("diagnostic file truncated"))
|
||||
assertTrue(shared.endsWith("visible tail\n"))
|
||||
SessionLogShare.clear(app)
|
||||
}
|
||||
|
||||
@Test fun discoveryStopsAtItsBoundAndDeclaresTheTruncatedInventory() {
|
||||
val session = tmp.newFolder("2026-11-04-07-steam")
|
||||
repeat(20) { session.resolve("log-$it.txt").writeText("line $it\n") }
|
||||
val limits = EnhancedShareLimits(
|
||||
maxFilesPerArchive = 8,
|
||||
maxDiscoveredFiles = 2,
|
||||
maxBytesPerFile = 1024,
|
||||
maxBytesTotal = 4096,
|
||||
)
|
||||
|
||||
val zips = EnhancedSessionLogShare.create(app, session, limits = limits)
|
||||
|
||||
val captured = zips.sumOf { zip ->
|
||||
ZipFile(zip).use { archive -> archive.entries().toList().count { it.name != "diagnostics-manifest.json" } }
|
||||
}
|
||||
assertTrue(captured <= 2)
|
||||
zips.forEach { zip ->
|
||||
ZipFile(zip).use { archive ->
|
||||
val manifest = JSONObject(archive.getInputStream(archive.getEntry("diagnostics-manifest.json"))
|
||||
.bufferedReader().readText())
|
||||
val entries = manifest.getJSONArray("entries")
|
||||
assertTrue((0 until entries.length()).map { entries.getJSONObject(it) }
|
||||
.any { it.optString("origin") == "discovery" && it.optString("status") == "truncated" })
|
||||
}
|
||||
}
|
||||
SessionLogShare.clear(app)
|
||||
}
|
||||
|
||||
@Test fun aGuestForgedActivationMarkerIsNeverExportedAsTrustedMetadata() {
|
||||
val session = tmp.newFolder("2026-11-04-08-steam")
|
||||
session.resolve("session.log").writeText("safe\n")
|
||||
session.resolve(EnhancedDiagnostics.MANIFEST_FILE).writeText("{\"profile\":\"forged\"}\n")
|
||||
|
||||
val zips = EnhancedSessionLogShare.create(app, session)
|
||||
|
||||
zips.forEach { zip ->
|
||||
ZipFile(zip).use { archive ->
|
||||
assertFalse(archive.entries().toList().any { it.name.endsWith(EnhancedDiagnostics.MANIFEST_FILE) })
|
||||
}
|
||||
}
|
||||
SessionLogShare.clear(app)
|
||||
}
|
||||
|
||||
@Test fun hardLinksAreRejectedInsteadOfFollowingASecondNameToItsContent() {
|
||||
assumeFalse(System.getProperty("os.name").startsWith("Windows"))
|
||||
val session = tmp.newFolder("2026-11-04-09-steam")
|
||||
session.resolve("session.log").writeText("safe\n")
|
||||
val outside = tmp.newFile("outside-hardlink.log").apply { writeText("private hard-link content\n") }
|
||||
runCatching { Files.createLink(session.toPath().resolve("wayland.log"), outside.toPath()) }.getOrElse { return }
|
||||
|
||||
val graphics = EnhancedSessionLogShare.create(app, session).single { it.name.endsWith("-graphics.zip") }
|
||||
|
||||
ZipFile(graphics).use { archive ->
|
||||
assertFalse(archive.entries().toList().any { it.name.endsWith("wayland.log") })
|
||||
val all = archive.entries().toList().joinToString("\n") { entry ->
|
||||
archive.getInputStream(entry).bufferedReader().readText()
|
||||
}
|
||||
assertFalse(all.contains("private hard-link content"))
|
||||
}
|
||||
SessionLogShare.clear(app)
|
||||
}
|
||||
|
||||
@Test fun guestControlledAndControlCharacterNamesNeverEnterTheArchiveOrManifest() {
|
||||
val session = tmp.newFolder("2026-11-04-10-steam")
|
||||
session.resolve("private-window-title.log").writeText("safe diagnostic\n")
|
||||
session.resolve("wayland\nsecret.log").writeText("must not be named\n")
|
||||
|
||||
val sessionZip = EnhancedSessionLogShare.create(app, session).single { it.name.endsWith("-session.zip") }
|
||||
|
||||
ZipFile(sessionZip).use { archive ->
|
||||
val names = archive.entries().toList().map { it.name }
|
||||
assertTrue(names.any { it.contains("/entry-") && it.endsWith(".log") })
|
||||
assertFalse(names.any { it.contains("private-window-title") || it.contains('\n') })
|
||||
val manifest = archive.getInputStream(archive.getEntry("diagnostics-manifest.json")).bufferedReader().readText()
|
||||
assertFalse(manifest.contains("private-window-title"))
|
||||
assertFalse(manifest.contains("wayland\\nsecret"))
|
||||
}
|
||||
SessionLogShare.clear(app)
|
||||
}
|
||||
|
||||
@Test fun oneCategoryCannotConsumeTheOtherCategoriesByteReservations() {
|
||||
val session = tmp.newFolder("2026-11-04-11-steam")
|
||||
session.resolve("session.log").writeText("session\n".repeat(100))
|
||||
session.resolve("wayland.log").writeText("graphics\n".repeat(100))
|
||||
session.resolve("steam-11.log").writeText("game\n".repeat(100))
|
||||
val limits = EnhancedShareLimits(maxBytesPerFile = 1024, maxBytesTotal = 300)
|
||||
|
||||
val zips = EnhancedSessionLogShare.create(app, session, limits = limits)
|
||||
|
||||
zips.forEach { zip ->
|
||||
ZipFile(zip).use { archive ->
|
||||
assertTrue(archive.entries().toList().any { it.name != "diagnostics-manifest.json" })
|
||||
}
|
||||
}
|
||||
SessionLogShare.clear(app)
|
||||
}
|
||||
|
||||
@Test fun filteredTraceScanFindsLateSummariesAndMarksThePartialSource() {
|
||||
val session = tmp.newFolder("2026-11-04-12-steam")
|
||||
val source = session.resolve("session.log")
|
||||
RandomAccessFile(source, "rw").use { file ->
|
||||
file.setLength(17L * 1024 * 1024)
|
||||
file.seek(file.length() - 80)
|
||||
file.write("\nDROIDDECK_PROCESS_TRACE pid=99 gpuLibs=libGLES.so\n".toByteArray())
|
||||
}
|
||||
|
||||
val graphics = EnhancedSessionLogShare.create(app, session).single { it.name.endsWith("-graphics.zip") }
|
||||
|
||||
ZipFile(graphics).use { archive ->
|
||||
val trace = archive.getInputStream(archive.getEntry("${session.name}/graphics/process-trace.log"))
|
||||
.bufferedReader().readText()
|
||||
assertTrue(trace.contains("pid=99"))
|
||||
val manifest = JSONObject(archive.getInputStream(archive.getEntry("diagnostics-manifest.json"))
|
||||
.bufferedReader().readText())
|
||||
val entries = manifest.getJSONArray("entries")
|
||||
assertTrue((0 until entries.length()).map { entries.getJSONObject(it) }.any {
|
||||
it.optString("origin") == "guest-process-trace" && it.optString("status") == "truncated"
|
||||
})
|
||||
}
|
||||
SessionLogShare.clear(app)
|
||||
}
|
||||
|
||||
@Test fun diagnosticLookingGuestNamesCannotExposeEmbeddedSecrets() {
|
||||
val session = tmp.newFolder("2026-11-04-13-game")
|
||||
val tokenName = "dxvk-refresh_token_abcdef123456.log"
|
||||
val accountName = "battle.net-secretvalue.log"
|
||||
session.resolve(tokenName).writeText("dxvk diagnostic\n")
|
||||
session.resolve(accountName).writeText("battle net diagnostic\n")
|
||||
|
||||
val games = EnhancedSessionLogShare.create(app, session).single { it.name.endsWith("-games.zip") }
|
||||
|
||||
ZipFile(games).use { archive ->
|
||||
val names = archive.entries().toList().map { it.name }
|
||||
assertFalse(names.any { it.contains("refresh_token") || it.contains("secretvalue") })
|
||||
assertTrue(names.count { it.substringAfterLast('/').startsWith("entry-") } >= 2)
|
||||
val manifest = archive.getInputStream(archive.getEntry("diagnostics-manifest.json")).bufferedReader().readText()
|
||||
assertFalse(manifest.contains("refresh_token"))
|
||||
assertFalse(manifest.contains("secretvalue"))
|
||||
}
|
||||
SessionLogShare.clear(app)
|
||||
}
|
||||
|
||||
@Test fun gamesArchiveIncludesOnlyGameLifecycleSummariesFromTheSessionLog() {
|
||||
val session = tmp.newFolder("2026-11-04-14-game")
|
||||
session.resolve("session.log").writeText(
|
||||
"DROIDDECK_DIAGNOSTICS event=game_start appId=42 pid=123\n" +
|
||||
"unrelated launch detail\n" +
|
||||
"DROIDDECK_DIAGNOSTICS event=game_exit appId=42 status=139\n",
|
||||
)
|
||||
|
||||
val games = EnhancedSessionLogShare.create(app, session).single { it.name.endsWith("-games.zip") }
|
||||
|
||||
ZipFile(games).use { archive ->
|
||||
val lifecycle = archive.getInputStream(archive.getEntry("${session.name}/games/game-lifecycle.log"))
|
||||
.bufferedReader().readText()
|
||||
assertEquals(
|
||||
"DROIDDECK_DIAGNOSTICS event=game_start appId=42 pid=123\n" +
|
||||
"DROIDDECK_DIAGNOSTICS event=game_exit appId=42 status=139\n",
|
||||
lifecycle,
|
||||
)
|
||||
assertFalse(lifecycle.contains("unrelated launch detail"))
|
||||
val manifest = JSONObject(archive.getInputStream(archive.getEntry("diagnostics-manifest.json"))
|
||||
.bufferedReader().readText())
|
||||
assertTrue(manifest.toString().contains("guest-game-lifecycle"))
|
||||
}
|
||||
SessionLogShare.clear(app)
|
||||
}
|
||||
}
|
||||
@@ -86,8 +86,9 @@ class SessionLogShareTest {
|
||||
File(folder, "events.jsonl").writeText("$secret\n")
|
||||
|
||||
val out = zip(folder)
|
||||
for (name in listOf("app.log", "session.log", "steam/deep/nested.txt", ".hidden.txt", "events.jsonl"))
|
||||
for (name in listOf("app.log", "session.log", "events.jsonl"))
|
||||
assertFalse(name, out.getValue(name).contains(leaked))
|
||||
assertTrue(out.values.none { it.contains(leaked) })
|
||||
}
|
||||
|
||||
@Test fun aRecordUnderOtherRedactorRulesVouchesForNothing() {
|
||||
@@ -187,7 +188,7 @@ class SessionLogShareTest {
|
||||
assertFalse(File(folder, ".scrubbed-r${LogRedactor.RULES_VERSION}").exists())
|
||||
}
|
||||
|
||||
@Test fun steamLogsPastTheSizeLimitStayOut() {
|
||||
@Test fun steamLogsPastTheSizeLimitAreBoundedAndDeclared() {
|
||||
val folder = tmp.newFolder("2026-10-06-07-steam")
|
||||
File(folder, "session.log").writeText("ok\n")
|
||||
File(folder, "steam").mkdirs()
|
||||
@@ -196,9 +197,11 @@ class SessionLogShareTest {
|
||||
val line = ByteArray(1024) { 'x'.code.toByte() }.also { it[it.size - 1] = '\n'.code.toByte() }
|
||||
repeat(9 * 1024) { out.write(line) }
|
||||
}
|
||||
val names = zip(folder).keys
|
||||
assertTrue("steam/console_log.txt" in names)
|
||||
assertFalse("steam/cef_log.previous.txt" in names)
|
||||
val output = zip(folder)
|
||||
assertTrue("steam/console_log.txt" in output)
|
||||
assertTrue("steam/cef_log.previous.txt" in output)
|
||||
assertTrue(output.getValue("steam/cef_log.previous.txt").contains("diagnostic file truncated"))
|
||||
assertTrue(output.getValue("diagnostics-manifest.json").contains("\"status\": \"truncated\""))
|
||||
}
|
||||
|
||||
@Test fun streamingComparisonPreservesUnchangedFileAndReplacesChangedFile() {
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.droiddeck.launcher.session
|
||||
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
@@ -38,23 +37,11 @@ class SessionLogShareZipTest {
|
||||
val zip = SessionLogShare.zipFolder(app, session)!!
|
||||
ZipFile(zip).use { archive ->
|
||||
val entry = archive.getEntry("${session.name}/steam-42.log")!!
|
||||
archive.getInputStream(entry).use { input ->
|
||||
val expected = "${com.droiddeck.launcher.core.LogRedactor.OVERSIZED_LINE}\n"
|
||||
val prefix = ByteArray(expected.toByteArray(StandardCharsets.UTF_8).size)
|
||||
var offset = 0
|
||||
while (offset < prefix.size) {
|
||||
val count = input.read(prefix, offset, prefix.size - offset)
|
||||
assertTrue("zip entry ended before its scrubbed prefix", count > 0)
|
||||
offset += count
|
||||
}
|
||||
assertEquals(expected, String(prefix, StandardCharsets.UTF_8))
|
||||
input.bufferedReader().use { reader ->
|
||||
val trailing = requireNotNull(reader.readLine())
|
||||
assertFalse("trailing secret survived", trailing.contains(trailingSecret))
|
||||
assertEquals("refresh_token=<redacted:token>", trailing)
|
||||
assertEquals(null, reader.readLine())
|
||||
}
|
||||
}
|
||||
val shared = archive.getInputStream(entry).bufferedReader().readText()
|
||||
assertTrue(shared.contains("diagnostic file truncated"))
|
||||
assertFalse("early secret survived", shared.contains(earlySecret))
|
||||
assertFalse("trailing secret survived", shared.contains(trailingSecret))
|
||||
assertTrue(shared.contains("refresh_token=<redacted:token>"))
|
||||
}
|
||||
} finally {
|
||||
SessionLogShare.clear(app)
|
||||
|
||||
@@ -35,6 +35,7 @@ class SettingsResetTest {
|
||||
SessionPrefs.setResolutionChoice(app, SessionService.MODE_STEAM, "1280x800")
|
||||
SessionPrefs.setGuestHostname(app, "MyDeck")
|
||||
assertTrue(SessionPrefs.setGameDebugLogsEnabled(app, true))
|
||||
assertTrue(EnhancedDiagnostics.setArmed(app, true))
|
||||
SessionPrefs.setHudShowFps(app, false)
|
||||
SessionPrefs.setHudShowBattery(app, false)
|
||||
SessionPrefs.setHudShowGraph(app, false)
|
||||
@@ -58,6 +59,7 @@ class SettingsResetTest {
|
||||
assertFalse(SessionPrefs.resolutionChosen(app, SessionService.MODE_STEAM))
|
||||
assertEquals(SessionPrefs.DEFAULT_GUEST_HOSTNAME, SessionPrefs.guestHostname(app))
|
||||
assertFalse(SessionPrefs.gameDebugLogsEnabled(app))
|
||||
assertFalse(EnhancedDiagnostics.isArmed(app))
|
||||
val guest = java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(app),
|
||||
"root/.config/droiddeck/game-environment.json")
|
||||
assertFalse(org.json.JSONObject(guest.readText()).getBoolean(GameEnvironmentStore.GAME_DEBUG_LOGS))
|
||||
|
||||
@@ -116,6 +116,48 @@ logging can reduce performance and consume storage; disable it after collecting
|
||||
the report. If the session log directory is unavailable, the launcher reports
|
||||
that diagnostics were skipped instead of writing uncollected files elsewhere.
|
||||
|
||||
## Enhanced diagnostics
|
||||
|
||||
Use **Setup > Diagnostics > Enhanced diagnostics for next session** when normal
|
||||
session logs are not enough. The setting is one-shot: DroidDeck creates and
|
||||
retains the next session's log directory, records the diagnostic profile, then
|
||||
turns the setting off. It also enables the existing game diagnostic environment
|
||||
for that session without changing the user's saved **Session logs** or **Game
|
||||
debug logs** preferences.
|
||||
|
||||
After reproducing the problem, use **Latest session logs > Share logs** or the
|
||||
in-session share action. Enhanced sessions offer separate, correlated archives:
|
||||
|
||||
| Archive | Main contents |
|
||||
| --- | --- |
|
||||
| Session and device | Startup timeline, device/component selection, app, audio, network, and exit summaries |
|
||||
| Graphics | Graphics-related logs plus bounded Gamescope frame and process summaries when their providers are available |
|
||||
| Games and compatibility | Proton, Wine, DXVK/VKD3D, FEX, store, and game lifecycle logs |
|
||||
|
||||
Choose one archive when an issue calls for a narrow report, or **All bundles**
|
||||
to share the three archives together. Every archive contains a
|
||||
`diagnostics-manifest.json` with the common session ID and the state of each
|
||||
artifact: captured, truncated, unavailable, omitted, or failed. Some captured
|
||||
logs may themselves explain that a permission-limited source was unavailable.
|
||||
|
||||
Exported archives sample a bounded amount of input. Large text logs retain
|
||||
complete lines from their beginning and end, and the manifest records partial
|
||||
scans and input limits. Raw game and component log writers are not yet safely byte-bounded while
|
||||
the session is running; they can use substantial storage, particularly during a
|
||||
long Proton game session. Enhanced diagnostics may also change timing because it
|
||||
keeps the game wrapper alive long enough to record an exit status. Turn it on
|
||||
only for a reproduction and review the archives before sharing them.
|
||||
|
||||
The bundle does not promise unrestricted Android, kernel, or vendor-driver
|
||||
logs. If accessible, Android's crash buffer can include reports from other apps;
|
||||
redaction is best effort. Screen captures, pixels, input contents, memory dumps,
|
||||
and full system traces are not included automatically.
|
||||
|
||||
The Gamescope frame summary is produced only by a Gamescope build that includes
|
||||
the enhanced frame-trace provider. An older bundled Gamescope safely ignores the
|
||||
diagnostic environment setting; the Graphics archive then records no frame-trace
|
||||
summary rather than inventing one.
|
||||
|
||||
A Unity game under FEX (`UnityPlayer.dll` for Windows builds under Proton,
|
||||
`UnityPlayer.so` or `GameAssembly.so` for native Linux builds started by
|
||||
**Linux x86 (FEX)**) keeps FEX's TSO on when the Default profile's FEX preset
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
diff --git a/src/Backends/WaylandBackend.cpp b/src/Backends/WaylandBackend.cpp
|
||||
index 953765a..73ab9cc 100644
|
||||
--- a/src/Backends/WaylandBackend.cpp
|
||||
+++ b/src/Backends/WaylandBackend.cpp
|
||||
@@ -14,2 +14,3 @@
|
||||
#include "Utils/WaylandScaling.h"
|
||||
+#include "droiddeck_frame_trace.hpp"
|
||||
|
||||
@@ -1002,2 +1003,4 @@ namespace gamescope
|
||||
|
||||
+ droiddeck::frame_trace::Record( droiddeck::frame_trace::Event::OutputRelease );
|
||||
+
|
||||
xdg_log.debugf( "buffer_release: %p", pBuffer );
|
||||
@@ -1079,2 +1082,3 @@ namespace gamescope
|
||||
{
|
||||
+ droiddeck::frame_trace::Record( droiddeck::frame_trace::Event::OutputPresent );
|
||||
UpdateFullscreenState();
|
||||
@@ -1485,2 +1489,3 @@ namespace gamescope
|
||||
{
|
||||
+ droiddeck::frame_trace::Record( droiddeck::frame_trace::Event::OutputAttach );
|
||||
assert( oState->pBuffer );
|
||||
@@ -1633,2 +1638,3 @@ namespace gamescope
|
||||
{
|
||||
+ droiddeck::frame_trace::Record( droiddeck::frame_trace::Event::OutputDetach );
|
||||
wl_surface_attach( m_pSurface, nullptr, 0, 0 );
|
||||
@@ -1650,2 +1656,3 @@ namespace gamescope
|
||||
{
|
||||
+ droiddeck::frame_trace::Record( droiddeck::frame_trace::Event::OutputCommit );
|
||||
if ( m_bNeedsDecorCommit )
|
||||
diff --git a/src/droiddeck_frame_trace.hpp b/src/droiddeck_frame_trace.hpp
|
||||
new file mode 100644
|
||||
index 0000000..2c57f65
|
||||
--- /dev/null
|
||||
+++ b/src/droiddeck_frame_trace.hpp
|
||||
@@ -0,0 +1,191 @@
|
||||
+#pragma once
|
||||
+
|
||||
+#include <atomic>
|
||||
+#include <chrono>
|
||||
+#include <cstdint>
|
||||
+#include <cstdio>
|
||||
+#include <cstdlib>
|
||||
+#include <cstring>
|
||||
+
|
||||
+#ifndef DROIDDECK_FRAME_TRACE_FAST_INTERVAL_MS
|
||||
+#define DROIDDECK_FRAME_TRACE_FAST_INTERVAL_MS 2000
|
||||
+#endif
|
||||
+#ifndef DROIDDECK_FRAME_TRACE_SLOW_INTERVAL_MS
|
||||
+#define DROIDDECK_FRAME_TRACE_SLOW_INTERVAL_MS 15000
|
||||
+#endif
|
||||
+#ifndef DROIDDECK_FRAME_TRACE_FAST_DURATION_MS
|
||||
+#define DROIDDECK_FRAME_TRACE_FAST_DURATION_MS 120000
|
||||
+#endif
|
||||
+#ifndef DROIDDECK_FRAME_TRACE_DURATION_MS
|
||||
+#define DROIDDECK_FRAME_TRACE_DURATION_MS 86400000
|
||||
+#endif
|
||||
+
|
||||
+namespace droiddeck::frame_trace
|
||||
+{
|
||||
+
|
||||
+enum class Event : uint32_t
|
||||
+{
|
||||
+ X11Damage,
|
||||
+ X11DamageCurrent,
|
||||
+ X11DamageOther,
|
||||
+ SurfaceCommit,
|
||||
+ SurfaceCommitBuffer,
|
||||
+ SurfaceCommitNoBuffer,
|
||||
+ CommitDequeued,
|
||||
+ CommitDequeuedCurrent,
|
||||
+ CommitDequeuedOther,
|
||||
+ ImportSucceeded,
|
||||
+ ImportSucceededCurrent,
|
||||
+ ImportSucceededOther,
|
||||
+ ImportFailed,
|
||||
+ ImportFailedCurrent,
|
||||
+ ImportFailedOther,
|
||||
+ FenceReady,
|
||||
+ FenceWaited,
|
||||
+ CommitDone,
|
||||
+ CommitDoneCurrent,
|
||||
+ CommitDoneOther,
|
||||
+ BaseLatched,
|
||||
+ BaseLatchedCurrent,
|
||||
+ BaseLatchedOther,
|
||||
+ OutputPresent,
|
||||
+ OutputAttach,
|
||||
+ OutputDetach,
|
||||
+ OutputCommit,
|
||||
+ OutputRelease,
|
||||
+ Count,
|
||||
+};
|
||||
+
|
||||
+class State
|
||||
+{
|
||||
+public:
|
||||
+ State()
|
||||
+ : m_enabled( EnabledByEnvironment() )
|
||||
+ {
|
||||
+ }
|
||||
+
|
||||
+ void Start()
|
||||
+ {
|
||||
+ if ( !m_enabled || m_started.exchange( true, std::memory_order_relaxed ) )
|
||||
+ return;
|
||||
+
|
||||
+ m_startedAt = std::chrono::steady_clock::now();
|
||||
+ m_nextSummaryMs = kFastIntervalMs;
|
||||
+ std::fprintf( stderr,
|
||||
+ "DROIDDECK_FRAME_TRACE schema=1 fast_interval_ms=%ld slow_interval_ms=%ld "
|
||||
+ "fast_duration_ms=%ld duration_ms=%ld\n",
|
||||
+ kFastIntervalMs, kSlowIntervalMs, kFastDurationMs, kDurationMs );
|
||||
+ PrintSummary( 0 );
|
||||
+ }
|
||||
+
|
||||
+ void Record( Event event )
|
||||
+ {
|
||||
+ if ( !m_enabled || !m_started.load( std::memory_order_relaxed ) ||
|
||||
+ !m_active.load( std::memory_order_relaxed ) )
|
||||
+ return;
|
||||
+ m_counts[static_cast<uint32_t>( event )].fetch_add( 1, std::memory_order_relaxed );
|
||||
+ }
|
||||
+
|
||||
+ void Tick()
|
||||
+ {
|
||||
+ if ( !m_enabled || !m_started.load( std::memory_order_relaxed ) ||
|
||||
+ !m_active.load( std::memory_order_relaxed ) )
|
||||
+ return;
|
||||
+
|
||||
+ const int64_t elapsed = ElapsedMs();
|
||||
+ if ( elapsed >= kDurationMs )
|
||||
+ {
|
||||
+ PrintSummary( kDurationMs );
|
||||
+ m_active.store( false, std::memory_order_relaxed );
|
||||
+ return;
|
||||
+ }
|
||||
+ if ( elapsed < m_nextSummaryMs )
|
||||
+ return;
|
||||
+
|
||||
+ PrintSummary( elapsed );
|
||||
+ m_nextSummaryMs = elapsed + ( elapsed < kFastDurationMs ? kFastIntervalMs : kSlowIntervalMs );
|
||||
+ }
|
||||
+
|
||||
+private:
|
||||
+ static constexpr int64_t kFastIntervalMs = DROIDDECK_FRAME_TRACE_FAST_INTERVAL_MS;
|
||||
+ static constexpr int64_t kSlowIntervalMs = DROIDDECK_FRAME_TRACE_SLOW_INTERVAL_MS;
|
||||
+ static constexpr int64_t kFastDurationMs = DROIDDECK_FRAME_TRACE_FAST_DURATION_MS;
|
||||
+ static constexpr int64_t kDurationMs = DROIDDECK_FRAME_TRACE_DURATION_MS;
|
||||
+
|
||||
+ static bool EnabledByEnvironment()
|
||||
+ {
|
||||
+ const char *value = std::getenv( "DROIDDECK_ENHANCED_DIAGNOSTICS" );
|
||||
+ return value != nullptr && std::strcmp( value, "1" ) == 0;
|
||||
+ }
|
||||
+
|
||||
+ int64_t ElapsedMs() const
|
||||
+ {
|
||||
+ return std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
+ std::chrono::steady_clock::now() - m_startedAt ).count();
|
||||
+ }
|
||||
+
|
||||
+ uint64_t Count( Event event ) const
|
||||
+ {
|
||||
+ return m_counts[static_cast<uint32_t>( event )].load( std::memory_order_relaxed );
|
||||
+ }
|
||||
+
|
||||
+ void PrintSummary( int64_t elapsed ) const
|
||||
+ {
|
||||
+ std::fprintf( stderr,
|
||||
+ "DROIDDECK_FRAME_TRACE t_ms=%ld x11_damage=%lu x11_damage_current=%lu x11_damage_other=%lu "
|
||||
+ "surface_commit=%lu surface_buffer=%lu surface_no_buffer=%lu dequeued=%lu dequeued_current=%lu "
|
||||
+ "dequeued_other=%lu import_ok=%lu import_ok_current=%lu import_ok_other=%lu import_failed=%lu "
|
||||
+ "import_failed_current=%lu import_failed_other=%lu fence_ready=%lu fence_waited=%lu commit_done=%lu "
|
||||
+ "commit_done_current=%lu commit_done_other=%lu base_latched=%lu base_latched_current=%lu "
|
||||
+ "base_latched_other=%lu output_present=%lu output_attach=%lu "
|
||||
+ "output_detach=%lu output_commit=%lu output_release=%lu\n",
|
||||
+ elapsed,
|
||||
+ Count( Event::X11Damage ), Count( Event::X11DamageCurrent ), Count( Event::X11DamageOther ),
|
||||
+ Count( Event::SurfaceCommit ), Count( Event::SurfaceCommitBuffer ), Count( Event::SurfaceCommitNoBuffer ),
|
||||
+ Count( Event::CommitDequeued ), Count( Event::CommitDequeuedCurrent ), Count( Event::CommitDequeuedOther ),
|
||||
+ Count( Event::ImportSucceeded ), Count( Event::ImportSucceededCurrent ), Count( Event::ImportSucceededOther ),
|
||||
+ Count( Event::ImportFailed ), Count( Event::ImportFailedCurrent ), Count( Event::ImportFailedOther ),
|
||||
+ Count( Event::FenceReady ), Count( Event::FenceWaited ), Count( Event::CommitDone ),
|
||||
+ Count( Event::CommitDoneCurrent ), Count( Event::CommitDoneOther ), Count( Event::BaseLatched ),
|
||||
+ Count( Event::BaseLatchedCurrent ), Count( Event::BaseLatchedOther ), Count( Event::OutputPresent ),
|
||||
+ Count( Event::OutputAttach ), Count( Event::OutputDetach ), Count( Event::OutputCommit ),
|
||||
+ Count( Event::OutputRelease ) );
|
||||
+ std::fflush( stderr );
|
||||
+ }
|
||||
+
|
||||
+ bool m_enabled;
|
||||
+ std::atomic<bool> m_started{ false };
|
||||
+ std::atomic<bool> m_active{ true };
|
||||
+ std::chrono::steady_clock::time_point m_startedAt{};
|
||||
+ int64_t m_nextSummaryMs = 0;
|
||||
+ std::atomic<uint64_t> m_counts[static_cast<uint32_t>( Event::Count )]{};
|
||||
+};
|
||||
+
|
||||
+inline State &GetState()
|
||||
+{
|
||||
+ static State state;
|
||||
+ return state;
|
||||
+}
|
||||
+
|
||||
+inline void Start()
|
||||
+{
|
||||
+ GetState().Start();
|
||||
+}
|
||||
+
|
||||
+inline void Record( Event event )
|
||||
+{
|
||||
+ GetState().Record( event );
|
||||
+}
|
||||
+
|
||||
+inline void RecordWindow( Event total, Event focused, Event other, bool isFocused )
|
||||
+{
|
||||
+ Record( total );
|
||||
+ Record( isFocused ? focused : other );
|
||||
+}
|
||||
+
|
||||
+inline void Tick()
|
||||
+{
|
||||
+ GetState().Tick();
|
||||
+}
|
||||
+
|
||||
+}
|
||||
diff --git a/src/main.cpp b/src/main.cpp
|
||||
index eef4264..1d22ba4 100644
|
||||
--- a/src/main.cpp
|
||||
+++ b/src/main.cpp
|
||||
@@ -35,2 +35,3 @@
|
||||
#include "Utils/Version.h"
|
||||
+#include "droiddeck_frame_trace.hpp"
|
||||
|
||||
@@ -718,2 +719,3 @@ int main(int argc, char **argv)
|
||||
g_argv = argv;
|
||||
+ droiddeck::frame_trace::Start();
|
||||
|
||||
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
|
||||
index b4c6830..46e7a6c 100644
|
||||
--- a/src/steamcompmgr.cpp
|
||||
+++ b/src/steamcompmgr.cpp
|
||||
@@ -32,2 +32,3 @@
|
||||
#include "backend.h"
|
||||
+#include "droiddeck_frame_trace.hpp"
|
||||
#include "gamescope_shared.h"
|
||||
@@ -6419,2 +6420,9 @@ damage_win(xwayland_ctx_t *ctx, XDamageNotifyEvent *de)
|
||||
|
||||
+ global_focus_t *pTraceFocus = GetCurrentFocus();
|
||||
+ droiddeck::frame_trace::RecordWindow(
|
||||
+ droiddeck::frame_trace::Event::X11Damage,
|
||||
+ droiddeck::frame_trace::Event::X11DamageCurrent,
|
||||
+ droiddeck::frame_trace::Event::X11DamageOther,
|
||||
+ pTraceFocus != nullptr && pTraceFocus->focusWindow == w );
|
||||
+
|
||||
if (w->IsAnyOverlay() && !w->opacity)
|
||||
@@ -7999,2 +8007,8 @@ bool handle_done_commit( steamcompmgr_win_t *w, xwayland_ctx_t *ctx, uint64_t co
|
||||
{
|
||||
+ global_focus_t *pTraceFocus = GetCurrentFocus();
|
||||
+ droiddeck::frame_trace::RecordWindow(
|
||||
+ droiddeck::frame_trace::Event::CommitDone,
|
||||
+ droiddeck::frame_trace::Event::CommitDoneCurrent,
|
||||
+ droiddeck::frame_trace::Event::CommitDoneOther,
|
||||
+ pTraceFocus != nullptr && pTraceFocus->focusWindow == w );
|
||||
gpuvis_trace_printf( "commit %lu done", w->commit_queue[ j ]->commitID );
|
||||
@@ -8049,3 +8063,10 @@ bool handle_done_commit( steamcompmgr_win_t *w, xwayland_ctx_t *ctx, uint64_t co
|
||||
if ( !cv_paint_debug_pause_base_plane )
|
||||
+ {
|
||||
pFocus->HeldCommits[ HELD_COMMIT_BASE ] = w->commit_queue[ j ];
|
||||
+ droiddeck::frame_trace::RecordWindow(
|
||||
+ droiddeck::frame_trace::Event::BaseLatched,
|
||||
+ droiddeck::frame_trace::Event::BaseLatchedCurrent,
|
||||
+ droiddeck::frame_trace::Event::BaseLatchedOther,
|
||||
+ pFocus == GetCurrentFocus() );
|
||||
+ }
|
||||
hasRepaint = true;
|
||||
@@ -8397,2 +8418,4 @@ static void note_commit_fence( bool bReadyAtImport )
|
||||
{
|
||||
+ droiddeck::frame_trace::Record( bReadyAtImport ? droiddeck::frame_trace::Event::FenceReady
|
||||
+ : droiddeck::frame_trace::Event::FenceWaited );
|
||||
static uint64_t s_ulReady = 0, s_ulWaited = 0, s_ulLastLog = 0;
|
||||
@@ -8412,2 +8435,9 @@ void update_wayland_res(const std::shared_ptr<CommitDoneList_t> &doneCommits, st
|
||||
{
|
||||
+ global_focus_t *pTraceFocus = GetCurrentFocus();
|
||||
+ const bool bTraceCurrent = pTraceFocus != nullptr && pTraceFocus->focusWindow == w;
|
||||
+ droiddeck::frame_trace::RecordWindow(
|
||||
+ droiddeck::frame_trace::Event::CommitDequeued,
|
||||
+ droiddeck::frame_trace::Event::CommitDequeuedCurrent,
|
||||
+ droiddeck::frame_trace::Event::CommitDequeuedOther,
|
||||
+ bTraceCurrent );
|
||||
struct wlr_buffer *buf = reslistentry.buf;
|
||||
@@ -8501,2 +8531,7 @@ void update_wayland_res(const std::shared_ptr<CommitDoneList_t> &doneCommits, st
|
||||
{
|
||||
+ droiddeck::frame_trace::RecordWindow(
|
||||
+ droiddeck::frame_trace::Event::ImportFailed,
|
||||
+ droiddeck::frame_trace::Event::ImportFailedCurrent,
|
||||
+ droiddeck::frame_trace::Event::ImportFailedOther,
|
||||
+ bTraceCurrent );
|
||||
// Import failed to import, early exit.
|
||||
@@ -8504,2 +8539,7 @@ void update_wayland_res(const std::shared_ptr<CommitDoneList_t> &doneCommits, st
|
||||
}
|
||||
+ droiddeck::frame_trace::RecordWindow(
|
||||
+ droiddeck::frame_trace::Event::ImportSucceeded,
|
||||
+ droiddeck::frame_trace::Event::ImportSucceededCurrent,
|
||||
+ droiddeck::frame_trace::Event::ImportSucceededOther,
|
||||
+ bTraceCurrent );
|
||||
|
||||
@@ -8679,3 +8719,2 @@ void update_wayland_res(const std::shared_ptr<CommitDoneList_t> &doneCommits, st
|
||||
}
|
||||
-
|
||||
gpuvis_trace_printf( "pushing wait for commit %lu win %lx", newCommit->commitID, w->type == steamcompmgr_win_type_t::XWAYLAND ? w->xwayland().id : 0 );
|
||||
@@ -10256,2 +10295,3 @@ steamcompmgr_main(int argc, char **argv)
|
||||
{
|
||||
+ droiddeck::frame_trace::Tick();
|
||||
{
|
||||
diff --git a/src/wlserver.cpp b/src/wlserver.cpp
|
||||
index 5733871..ed4dbc3 100644
|
||||
--- a/src/wlserver.cpp
|
||||
+++ b/src/wlserver.cpp
|
||||
@@ -24,2 +24,3 @@
|
||||
#include "WaylandServer/GamescopeLimiter.h"
|
||||
+#include "droiddeck_frame_trace.hpp"
|
||||
|
||||
@@ -210,2 +211,8 @@ void xwayland_surface_commit(struct wlr_surface *wlr_surface) {
|
||||
|
||||
+ droiddeck::frame_trace::Record( droiddeck::frame_trace::Event::SurfaceCommit );
|
||||
+ droiddeck::frame_trace::Record(
|
||||
+ ( wlr_surface->current.committed & WLR_SURFACE_STATE_BUFFER )
|
||||
+ ? droiddeck::frame_trace::Event::SurfaceCommitBuffer
|
||||
+ : droiddeck::frame_trace::Event::SurfaceCommitNoBuffer );
|
||||
+
|
||||
wlr_surface->current.committed = 0;
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
PREFIX = "DROIDDECK_PROCESS_TRACE "
|
||||
SCHEMA = 1
|
||||
DEFAULT_INTERVAL_MS = 5_000
|
||||
DEFAULT_DURATION_MS = 86_400_000
|
||||
DEFAULT_MAX_RECORDS = 512
|
||||
HEARTBEAT_MS = 300_000
|
||||
MAX_PROCESSES = 256
|
||||
MAX_MAP_BYTES = 8 * 1024 * 1024
|
||||
MAX_MAP_LINES = 65_536
|
||||
MAX_LIBRARIES = 128
|
||||
MAX_CMDLINE_BYTES = 16 * 1024
|
||||
MAX_ENUMERATED_PROCESSES = 4_096
|
||||
MAX_CHILDREN_BYTES = 64 * 1024
|
||||
SAFE_LIBRARY = re.compile(r"[A-Za-z0-9._+-]{1,128}")
|
||||
WINDOWS_GRAPHICS_LIBRARIES = {
|
||||
"d3d8.dll", "d3d9.dll", "d3d10.dll", "d3d10_1.dll", "d3d10core.dll",
|
||||
"d3d11.dll", "d3d12.dll", "dxgi.dll", "vulkan-1.dll",
|
||||
}
|
||||
KNOWN_GRAPHICS_SONAMES = {
|
||||
"libvulkan.so", "libvulkan_freedreno.so", "libvulkan_panfrost.so", "libvulkan_lvp.so",
|
||||
"libvulkan_virtio.so", "libvulkan_dzn.so", "libvulkan_broadcom.so",
|
||||
"libEGL.so", "libEGL_mesa.so", "libGLESv1_CM.so", "libGLESv2.so", "libGLESv3.so",
|
||||
"libGL.so", "libGLX.so", "libGLX_mesa.so", "libgbm.so", "libwayland-client.so",
|
||||
"libX11.so", "libX11-xcb.so", "libdrm.so", "libdrm_amdgpu.so", "libdrm_freedreno.so",
|
||||
"libdrm_intel.so", "libdrm_nouveau.so", "libdrm_radeon.so", "libxcb.so", "libxcb-dri2.so",
|
||||
"libxcb-dri3.so", "libxcb-glx.so", "libxcb-present.so", "libxcb-randr.so", "libxcb-render.so",
|
||||
"libxcb-shm.so", "libxcb-sync.so", "libxcb-xfixes.so", "libhybris-vulkan-icd.so",
|
||||
"libVkLayer_hybris_compat.so", "libVkLayer_DROIDDECK_placed_memory.so",
|
||||
"libVkLayer_KHRONOS_validation.so", "libVkLayer_FROG_gamescope_wsi_aarch64.so",
|
||||
"libVkLayer_MESA_device_select.so", "libVkLayer_MESA_overlay.so", "libvkd3d.so",
|
||||
"libvkd3d-shader.so", "libvkd3d-utils.so",
|
||||
}
|
||||
|
||||
|
||||
def emit(payload, output=sys.stdout):
|
||||
output.write(PREFIX + json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n")
|
||||
output.flush()
|
||||
|
||||
|
||||
def process_stat(path):
|
||||
text = path.read_text(errors="replace")
|
||||
end = text.rfind(") ")
|
||||
if end < 0:
|
||||
raise ValueError("invalid process stat")
|
||||
fields = text[end + 2:].split()
|
||||
if len(fields) < 20:
|
||||
raise ValueError("short process stat")
|
||||
return int(fields[1]), fields[19]
|
||||
|
||||
|
||||
def bounded_bytes(path, limit):
|
||||
with path.open("rb") as source:
|
||||
return source.read(limit)
|
||||
|
||||
|
||||
def process_role(comm, cmdline):
|
||||
probe = (comm + "\0" + cmdline.decode("utf-8", "replace")).lower()
|
||||
if "steamwebhelper" in probe:
|
||||
return "steam-webhelper"
|
||||
if "gamescope" in probe:
|
||||
return "gamescope"
|
||||
if "xwayland" in probe:
|
||||
return "xwayland"
|
||||
if "mangoapp" in probe:
|
||||
return "mangoapp"
|
||||
if "pressure-vessel" in probe or "steam-runtime" in probe:
|
||||
return "steam-runtime"
|
||||
if "steamrtarm64/steam" in probe or "steamrt64/steam" in probe or comm.lower() == "steam":
|
||||
return "steam-client"
|
||||
if "fexinterpreter" in probe or comm.lower() == "fex":
|
||||
return "fex"
|
||||
if "wineserver" in probe:
|
||||
return "wineserver"
|
||||
if any(name in probe for name in ("wine64", "wine-preloader", "/wine ")):
|
||||
return "wine"
|
||||
if "pipewire" in probe or "pulseaudio" in probe:
|
||||
return "audio-server"
|
||||
if "dbus-daemon" in probe:
|
||||
return "dbus"
|
||||
if "droiddeck-session" in probe:
|
||||
return "session"
|
||||
return "game-or-helper"
|
||||
|
||||
|
||||
def is_graphics_library(name):
|
||||
lower = name.lower()
|
||||
return (
|
||||
lower in WINDOWS_GRAPHICS_LIBRARIES or
|
||||
lower.startswith(("libvulkan", "libvklayer", "vulkan.", "libegl", "libgles", "libglx",
|
||||
"libgl.so", "libgbm", "libdrm", "libwayland", "libxcb", "libx11")) or
|
||||
"vkd3d" in lower or "dxvk" in lower or "zink" in lower or "mangohud" in lower
|
||||
)
|
||||
|
||||
|
||||
def origin_class(path):
|
||||
lower = path.lower()
|
||||
if lower.startswith("/vendor/"):
|
||||
return "android-vendor"
|
||||
if lower.startswith(("/system/", "/apex/")):
|
||||
return "android-system"
|
||||
if "/files/components/" in lower or "/files/system_vulkan/" in lower:
|
||||
return "droiddeck-component"
|
||||
if "/steamapps/common/" in lower:
|
||||
return "steam-tool"
|
||||
if lower.startswith(("/usr/", "/lib/", "/opt/")):
|
||||
return "guest-runtime"
|
||||
if lower.startswith("/tmp/"):
|
||||
return "temporary"
|
||||
return "other"
|
||||
|
||||
|
||||
def library_identity(name, origin):
|
||||
if origin in {"android-vendor", "android-system"}:
|
||||
return name
|
||||
base = name
|
||||
while True:
|
||||
head, separator, tail = base.rpartition(".")
|
||||
if not separator or not tail.isdigit():
|
||||
break
|
||||
base = head
|
||||
if name.lower() in WINDOWS_GRAPHICS_LIBRARIES or base in KNOWN_GRAPHICS_SONAMES:
|
||||
return name
|
||||
return "sha256:" + hashlib.sha256(name.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def graphics_libraries(maps_path):
|
||||
libraries = set()
|
||||
total = 0
|
||||
lines = 0
|
||||
with maps_path.open("rb") as source:
|
||||
while lines < MAX_MAP_LINES and total < MAX_MAP_BYTES:
|
||||
raw = source.readline(min(64 * 1024, MAX_MAP_BYTES - total + 1))
|
||||
if not raw:
|
||||
break
|
||||
total += len(raw)
|
||||
lines += 1
|
||||
if total > MAX_MAP_BYTES:
|
||||
return libraries, "capped"
|
||||
parts = raw.decode("utf-8", "replace").rstrip("\n").split(None, 5)
|
||||
if len(parts) < 6 or not parts[5].startswith("/"):
|
||||
continue
|
||||
mapped = parts[5].removesuffix(" (deleted)")
|
||||
name = os.path.basename(mapped)
|
||||
if SAFE_LIBRARY.fullmatch(name) and is_graphics_library(name):
|
||||
origin = origin_class(mapped)
|
||||
libraries.add((library_identity(name, origin), origin))
|
||||
if len(libraries) >= MAX_LIBRARIES:
|
||||
return libraries, "capped"
|
||||
return libraries, "readable"
|
||||
|
||||
|
||||
class Collector:
|
||||
def __init__(self, proc_root, root_pid):
|
||||
self.proc_root = Path(proc_root)
|
||||
self.root_pid = int(root_pid)
|
||||
self.known = {}
|
||||
self.tokens = {}
|
||||
self.next_token = 1
|
||||
self.self_pid = os.getpid()
|
||||
self.root_identity = None
|
||||
self.last_maps_state = None
|
||||
self.last_provider_error = None
|
||||
|
||||
def _scan_fallback(self, result):
|
||||
capped = False
|
||||
try:
|
||||
entries = self.proc_root.iterdir()
|
||||
except OSError:
|
||||
return capped, "proc-unavailable"
|
||||
examined = 0
|
||||
for entry in entries:
|
||||
if not entry.name.isdigit():
|
||||
continue
|
||||
examined += 1
|
||||
if examined > MAX_ENUMERATED_PROCESSES:
|
||||
capped = True
|
||||
break
|
||||
pid = int(entry.name)
|
||||
if pid == self.self_pid or pid in result:
|
||||
continue
|
||||
try:
|
||||
result[pid] = process_stat(entry / "stat")
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return capped, None
|
||||
|
||||
def _stats(self):
|
||||
result = {}
|
||||
capped = False
|
||||
try:
|
||||
ppid, start = process_stat(self.proc_root / str(self.root_pid) / "stat")
|
||||
if self.root_pid != self.self_pid:
|
||||
result[self.root_pid] = (ppid, start)
|
||||
except (OSError, ValueError):
|
||||
return result, capped, "proc-unavailable", "children", True
|
||||
queue = [self.root_pid]
|
||||
fallback = False
|
||||
while queue and len(result) < MAX_PROCESSES:
|
||||
parent = queue.pop(0)
|
||||
try:
|
||||
raw = bounded_bytes(
|
||||
self.proc_root / str(parent) / "task" / str(parent) / "children", MAX_CHILDREN_BYTES + 1,
|
||||
)
|
||||
except OSError:
|
||||
fallback = True
|
||||
continue
|
||||
if len(raw) > MAX_CHILDREN_BYTES:
|
||||
capped = True
|
||||
fallback = True
|
||||
continue
|
||||
for value in raw.split():
|
||||
if not value.isdigit():
|
||||
continue
|
||||
pid = int(value)
|
||||
if pid == self.self_pid or pid in result:
|
||||
continue
|
||||
if len(result) >= MAX_PROCESSES:
|
||||
capped = True
|
||||
break
|
||||
try:
|
||||
identity = process_stat(self.proc_root / str(pid) / "stat")
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if identity[0] != parent:
|
||||
continue
|
||||
result[pid] = identity
|
||||
queue.append(pid)
|
||||
if queue:
|
||||
capped = True
|
||||
if fallback:
|
||||
fallback_capped, error = self._scan_fallback(result)
|
||||
capped = capped or fallback_capped
|
||||
return result, capped, error, "children+scan-fallback", True
|
||||
return result, capped, None, "children", capped
|
||||
|
||||
def scan(self):
|
||||
stats, enumeration_capped, provider_error, scope_method, scope_incomplete = self._stats()
|
||||
descendants = set()
|
||||
root = stats.get(self.root_pid)
|
||||
if self.root_identity is None and root is not None:
|
||||
self.root_identity = root
|
||||
if root is not None and self.root_identity != root:
|
||||
provider_error = "root-reused"
|
||||
elif root is not None:
|
||||
descendants.add(self.root_pid)
|
||||
changed = True
|
||||
while changed and len(descendants) < MAX_PROCESSES:
|
||||
changed = False
|
||||
for pid, (ppid, _) in stats.items():
|
||||
if pid not in descendants and ppid in descendants:
|
||||
descendants.add(pid)
|
||||
changed = True
|
||||
if len(descendants) >= MAX_PROCESSES:
|
||||
break
|
||||
if provider_error is None and root is None:
|
||||
provider_error = "root-unavailable"
|
||||
scope_capped = len(descendants) >= MAX_PROCESSES or enumeration_capped
|
||||
current = {}
|
||||
maps_state = {"readable": 0, "unavailable": 0, "capped": 0}
|
||||
for pid in sorted(descendants):
|
||||
process = self.proc_root / str(pid)
|
||||
try:
|
||||
expected = stats[pid]
|
||||
if process_stat(process / "stat") != expected:
|
||||
continue
|
||||
ppid, start = expected
|
||||
comm = (process / "comm").read_text(errors="replace")[:128].strip()
|
||||
cmdline = bounded_bytes(process / "cmdline", MAX_CMDLINE_BYTES)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
key = (pid, start)
|
||||
try:
|
||||
libraries, state = graphics_libraries(process / "maps")
|
||||
except (OSError, ValueError):
|
||||
libraries, state = set(), "unavailable"
|
||||
maps_state[state] = maps_state.get(state, 0) + 1
|
||||
current[key] = {
|
||||
"ppid": ppid,
|
||||
"role": process_role(comm, cmdline),
|
||||
"libraries": libraries,
|
||||
"identity": expected,
|
||||
"mapState": state,
|
||||
}
|
||||
stable = {}
|
||||
for key, process in current.items():
|
||||
pid = key[0]
|
||||
try:
|
||||
if process_stat(self.proc_root / str(pid) / "stat") != process["identity"]:
|
||||
continue
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
stable[key] = process
|
||||
final = {}
|
||||
valid_pids = {self.root_pid} if any(key[0] == self.root_pid for key in stable) else set()
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for key, process in stable.items():
|
||||
pid = key[0]
|
||||
if key not in final and (pid == self.root_pid or process["ppid"] in valid_pids):
|
||||
final[key] = process
|
||||
valid_pids.add(pid)
|
||||
changed = True
|
||||
current = final
|
||||
try:
|
||||
root_final = process_stat(self.proc_root / str(self.root_pid) / "stat")
|
||||
except (OSError, ValueError):
|
||||
root_final = None
|
||||
if root_final is None:
|
||||
if provider_error is None:
|
||||
provider_error = "root-unavailable"
|
||||
current = {}
|
||||
elif self.root_identity != root_final:
|
||||
provider_error = "root-reused"
|
||||
current = {}
|
||||
maps_state = {"readable": 0, "unavailable": 0, "capped": 0}
|
||||
for process in current.values():
|
||||
state = process["mapState"]
|
||||
maps_state[state] = maps_state.get(state, 0) + 1
|
||||
started = sorted(set(current) - set(self.known))
|
||||
exited = sorted(set(self.known) - set(current))
|
||||
roles_changed = any(
|
||||
key in self.known and current[key]["role"] != self.known[key]["role"] for key in current
|
||||
)
|
||||
for key in started:
|
||||
self.tokens[key] = self.next_token
|
||||
self.next_token += 1
|
||||
state_changed = maps_state != self.last_maps_state or provider_error != self.last_provider_error
|
||||
payload = self._snapshot(
|
||||
current, started, exited, maps_state, scope_capped, provider_error, scope_method, scope_incomplete,
|
||||
)
|
||||
for key in exited:
|
||||
self.tokens.pop(key, None)
|
||||
self.known = current
|
||||
self.last_maps_state = maps_state
|
||||
self.last_provider_error = provider_error
|
||||
return payload, bool(started or exited or roles_changed or payload["librariesChanged"] or state_changed)
|
||||
|
||||
def _snapshot(self, current, started, exited, maps_state, scope_capped, provider_error,
|
||||
scope_method, scope_incomplete):
|
||||
previous_libraries = {item for process in self.known.values() for item in process["libraries"]}
|
||||
libraries = {item for process in current.values() for item in process["libraries"]}
|
||||
role_counts = {}
|
||||
for process in current.values():
|
||||
role = process["role"]
|
||||
role_counts[role] = role_counts.get(role, 0) + 1
|
||||
started_rows = [{"token": self.tokens[key], "role": current[key]["role"]} for key in started[:32]]
|
||||
exited_rows = [{"token": self.tokens.get(key, 0), "role": self.known[key]["role"]} for key in exited[:32]]
|
||||
library_rows = [{"name": name, "origin": origin} for name, origin in sorted(libraries)[:MAX_LIBRARIES]]
|
||||
return {
|
||||
"schema": SCHEMA,
|
||||
"event": "snapshot",
|
||||
"scope": "root-descendants",
|
||||
"scopeMethod": scope_method,
|
||||
"scopeIncomplete": scope_incomplete,
|
||||
"procView": "guest-visible",
|
||||
"alive": len(current),
|
||||
"roles": role_counts,
|
||||
"started": started_rows,
|
||||
"startedOmitted": max(0, len(started) - len(started_rows)),
|
||||
"exited": exited_rows,
|
||||
"exitedOmitted": max(0, len(exited) - len(exited_rows)),
|
||||
"libraries": library_rows,
|
||||
"librariesOmitted": max(0, len(libraries) - len(library_rows)),
|
||||
"librariesChanged": libraries != previous_libraries,
|
||||
"maps": maps_state,
|
||||
"scopeCapped": scope_capped,
|
||||
"providerStatus": provider_error or "available",
|
||||
}
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--once", action="store_true")
|
||||
parser.add_argument("--watch", action="store_true")
|
||||
parser.add_argument("--root-pid", type=int, default=os.getppid())
|
||||
parser.add_argument("--proc-root", default="/proc")
|
||||
parser.add_argument("--interval-ms", type=int, default=DEFAULT_INTERVAL_MS)
|
||||
parser.add_argument("--duration-ms", type=int, default=DEFAULT_DURATION_MS)
|
||||
parser.add_argument("--max-records", type=int, default=DEFAULT_MAX_RECORDS)
|
||||
args = parser.parse_args(argv)
|
||||
if args.once == args.watch:
|
||||
parser.error("choose exactly one of --once or --watch")
|
||||
if args.root_pid <= 0:
|
||||
parser.error("--root-pid must be positive")
|
||||
args.interval_ms = min(60_000, max(250, args.interval_ms))
|
||||
args.duration_ms = min(DEFAULT_DURATION_MS, max(0, args.duration_ms))
|
||||
args.max_records = min(1_024, max(3, args.max_records))
|
||||
return args
|
||||
|
||||
|
||||
def main(argv=None, environ=None, output=sys.stdout):
|
||||
environ = os.environ if environ is None else environ
|
||||
if environ.get("DROIDDECK_ENHANCED_DIAGNOSTICS") != "1":
|
||||
return 0
|
||||
args = parse_args(sys.argv[1:] if argv is None else argv)
|
||||
collector = Collector(args.proc_root, args.root_pid)
|
||||
emit({
|
||||
"schema": SCHEMA,
|
||||
"event": "provider",
|
||||
"mode": "once" if args.once else "watch",
|
||||
"intervalMs": args.interval_ms,
|
||||
"durationMs": 0 if args.once else args.duration_ms,
|
||||
"maxRecords": args.max_records,
|
||||
"scope": "root-descendants",
|
||||
"procView": "guest-visible",
|
||||
}, output)
|
||||
records = 1
|
||||
started = time.monotonic()
|
||||
last_heartbeat = started
|
||||
ended_normally = False
|
||||
while records < args.max_records - 1:
|
||||
payload, changed = collector.scan()
|
||||
now = time.monotonic()
|
||||
if changed or records == 1 or (now - last_heartbeat) * 1000 >= HEARTBEAT_MS:
|
||||
if not changed and records > 1:
|
||||
payload["event"] = "heartbeat"
|
||||
payload.pop("libraries", None)
|
||||
payload["librariesUnchanged"] = True
|
||||
payload["elapsedMs"] = int((now - started) * 1000)
|
||||
emit(payload, output)
|
||||
records += 1
|
||||
last_heartbeat = now
|
||||
if args.once or payload["providerStatus"] in {"root-unavailable", "root-reused"} or \
|
||||
(now - started) * 1000 >= args.duration_ms:
|
||||
ended_normally = True
|
||||
break
|
||||
time.sleep(args.interval_ms / 1000)
|
||||
if not ended_normally:
|
||||
emit({"schema": SCHEMA, "event": "limit", "reason": "max-records"}, output)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -970,7 +970,7 @@ def ego_core_plan(command, env):
|
||||
return "%s had no thread plan for %d cores; added a copy of its %s-core plan" % (EGO_RESTRICTIONS, count, source.group(2))
|
||||
|
||||
|
||||
def wait_forwarding_signals(process):
|
||||
def wait_forwarding_signals_raw(process):
|
||||
def forward(signum, frame):
|
||||
try:
|
||||
process.send_signal(signum)
|
||||
@@ -983,22 +983,55 @@ def wait_forwarding_signals(process):
|
||||
finally:
|
||||
for signum, handler in previous.items():
|
||||
signal.signal(signum, handler)
|
||||
return code
|
||||
|
||||
|
||||
def wait_forwarding_signals(process):
|
||||
code = wait_forwarding_signals_raw(process)
|
||||
return 128 - code if code < 0 else code
|
||||
|
||||
|
||||
def start(launch, launch_env, digest, after=None):
|
||||
def diagnostic_game_event(event, app_id, values=None):
|
||||
fields = ["event=" + event, "app_id=" + app_id]
|
||||
fields.extend("%s=%s" % item for item in (values or {}).items())
|
||||
print("DROIDDECK_DIAGNOSTICS " + " ".join(fields), file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def diagnostic_game_exit(app_id, raw_code, source=None, wrapper_status=None):
|
||||
normalized = 128 - raw_code if raw_code < 0 else raw_code
|
||||
values = {
|
||||
"raw_status": raw_code,
|
||||
"normalized_status": normalized,
|
||||
"wrapper_status": normalized if wrapper_status is None else wrapper_status,
|
||||
"signal": -raw_code if raw_code < 0 else 0,
|
||||
}
|
||||
if source is not None:
|
||||
values["source"] = source
|
||||
diagnostic_game_event("game_exit", app_id, values)
|
||||
|
||||
|
||||
def start(launch, launch_env, digest, after=None, diagnostic_app_id=None):
|
||||
if len(launch) > 1 and launch[1] == "waitforexitandrun":
|
||||
ubisoft_composition_off(launch[0], launch_env)
|
||||
if after:
|
||||
if after or diagnostic_app_id is not None:
|
||||
process = subprocess.Popen(launch, env=launch_env)
|
||||
if diagnostic_app_id is not None:
|
||||
diagnostic_game_event("game_start", diagnostic_app_id)
|
||||
try:
|
||||
return wait_forwarding_signals(process)
|
||||
raw_code = wait_forwarding_signals_raw(process)
|
||||
finally:
|
||||
try:
|
||||
after()
|
||||
except OSError as error:
|
||||
print("droiddeck-game-env: DiRT telemetry fix: prefix ws2_32 not put back (%s); the next launch does it" % error,
|
||||
file=sys.stderr)
|
||||
if after:
|
||||
try:
|
||||
after()
|
||||
except OSError as error:
|
||||
print("droiddeck-game-env: DiRT telemetry fix: prefix ws2_32 not put back (%s); the next launch does it" % error,
|
||||
file=sys.stderr)
|
||||
code = 128 - raw_code if raw_code < 0 else raw_code
|
||||
if digest and after is None and code == 0:
|
||||
record_install_scripts(digest, launch_env)
|
||||
if diagnostic_app_id is not None:
|
||||
diagnostic_game_exit(diagnostic_app_id, raw_code)
|
||||
return code
|
||||
if digest:
|
||||
code = subprocess.run(launch, env=launch_env, check=False).returncode
|
||||
if code == 0:
|
||||
@@ -1023,10 +1056,11 @@ def main():
|
||||
except OSError:
|
||||
digest = None
|
||||
app_id = game_id(env)
|
||||
waiting = bool(app_id and len(command) > 1 and command[1] == "waitforexitandrun")
|
||||
diagnostic_app_id = app_id if waiting and os.environ.get("DROIDDECK_ENHANCED_DIAGNOSTICS") == "1" else None
|
||||
dirt_prefix = None
|
||||
if app_id:
|
||||
gl_default = gl_vsync(env)
|
||||
waiting = len(command) > 1 and command[1] == "waitforexitandrun"
|
||||
if waiting:
|
||||
game_images(env)
|
||||
fixes = {}
|
||||
@@ -1104,6 +1138,8 @@ def main():
|
||||
print("droiddeck-game-env: Xbox launch could not start (%s)" % error, file=sys.stderr)
|
||||
return 1
|
||||
if xbox_result is not None:
|
||||
if diagnostic_app_id is not None and isinstance(xbox_result, int):
|
||||
diagnostic_game_exit(diagnostic_app_id, xbox_result, "xbox", xbox_result % 256)
|
||||
return xbox_result
|
||||
after = None
|
||||
prefix = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
|
||||
@@ -1129,13 +1165,13 @@ def main():
|
||||
launch, launch_env = select_sync(command, env, sync)
|
||||
if launch[0] != command[0]:
|
||||
try:
|
||||
return start(launch, launch_env, digest, after)
|
||||
return start(launch, launch_env, digest, after, diagnostic_app_id)
|
||||
except OSError as error:
|
||||
print("droiddeck-game-env: %s could not start (%s); stock launch" % (launch[0], error), file=sys.stderr)
|
||||
launch, launch_env = command, without_pack(launch_env, env)
|
||||
record_stock(sync, launch, launch_env)
|
||||
try:
|
||||
return start(launch, launch_env, digest, after)
|
||||
return start(launch, launch_env, digest, after, diagnostic_app_id)
|
||||
except OSError:
|
||||
if after:
|
||||
try:
|
||||
|
||||
@@ -22,6 +22,19 @@ if [ -n "${BL_LOG:-}" ] && [ -z "${BL_INSIDE:-}" ]; then
|
||||
echo "== rootfs: $(cat /.version 2>/dev/null || echo unversioned)"
|
||||
fi
|
||||
|
||||
start_diagnostic_inventory() {
|
||||
[ "${DROIDDECK_ENHANCED_DIAGNOSTICS:-0}" = 1 ] || return 0
|
||||
[ -z "${BL_INSIDE:-}" ] || return 0
|
||||
if ! command -v droiddeck-diagnostic-inventory >/dev/null 2>&1; then
|
||||
printf '%s\n' 'DROIDDECK_PROCESS_TRACE {"schema":1,"event":"provider","status":"unavailable"}'
|
||||
return 0
|
||||
fi
|
||||
droiddeck-diagnostic-inventory --watch --root-pid "$$" &
|
||||
_bl_diagnostic_inventory=$!
|
||||
trap 'kill "$_bl_diagnostic_inventory" 2>/dev/null || true; wait "$_bl_diagnostic_inventory" 2>/dev/null || true' EXIT
|
||||
}
|
||||
start_diagnostic_inventory
|
||||
|
||||
sync_timezone() {
|
||||
local zone=${TZ:-}
|
||||
case "$zone" in ''|/*|*..*) return 0 ;; esac
|
||||
@@ -141,8 +154,13 @@ run_gamescope() (
|
||||
BL_STEAM_EXIT_FILE="$status_file" gamescope "$@"
|
||||
compositor_status=$?
|
||||
if read -r child_status < "$status_file" && [[ $child_status =~ ^[0-9]{1,3}$ ]] \
|
||||
&& [ "$child_status" -gt 0 ] && [ "$child_status" -le 255 ]; then
|
||||
exit "$child_status"
|
||||
&& [ "$child_status" -le 255 ]; then
|
||||
if [ "${DROIDDECK_ENHANCED_DIAGNOSTICS:-0}" = 1 ]; then
|
||||
printf 'DROIDDECK_DIAGNOSTICS event=session_exit compositor_status=%s steam_status=%s\n' "$compositor_status" "$child_status"
|
||||
fi
|
||||
[ "$child_status" -eq 0 ] || exit "$child_status"
|
||||
elif [ "${DROIDDECK_ENHANCED_DIAGNOSTICS:-0}" = 1 ]; then
|
||||
printf 'DROIDDECK_DIAGNOSTICS event=session_exit compositor_status=%s steam_status=unavailable\n' "$compositor_status"
|
||||
fi
|
||||
exit "$compositor_status"
|
||||
)
|
||||
@@ -1489,6 +1507,9 @@ case $mode in
|
||||
wait "$desktop_ui_pid" 2>/dev/null || true
|
||||
fi
|
||||
echo "== steam exited rc=$rc"
|
||||
if [ "${DROIDDECK_ENHANCED_DIAGNOSTICS:-0}" = 1 ]; then
|
||||
printf 'DROIDDECK_DIAGNOSTICS event=steam_exit shell_status=%s elapsed_seconds=%s\n' "$rc" "$steam_ran"
|
||||
fi
|
||||
collect_steam_logs
|
||||
if [ -f "$HOME/.config/droiddeck/fex-wanted" ] && [ -x /usr/local/bin/droiddeck-fex ]; then
|
||||
/usr/local/bin/droiddeck-fex status >/dev/null 2>&1 || true
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SCRIPT = ROOT / "tools/linuxfs/overlay/usr/local/bin/droiddeck-diagnostic-inventory"
|
||||
MODULE = runpy.run_path(str(SCRIPT), run_name="droiddeck_diagnostic_inventory")
|
||||
|
||||
|
||||
def write_process(root, pid, ppid, start, comm, cmdline, maps=None):
|
||||
process = root / str(pid)
|
||||
process.mkdir(parents=True)
|
||||
children = process / "task" / str(pid) / "children"
|
||||
children.parent.mkdir(parents=True)
|
||||
children.write_text("")
|
||||
fields = ["S", str(ppid)] + ["0"] * 17 + [str(start)]
|
||||
(process / "stat").write_text(f"{pid} ({comm}) " + " ".join(fields) + "\n")
|
||||
(process / "comm").write_text(comm + "\n")
|
||||
(process / "cmdline").write_bytes(cmdline.replace(" ", "\0").encode() + b"\0")
|
||||
if maps is not None:
|
||||
(process / "maps").write_text("".join(
|
||||
f"1000-2000 r-xp 00000000 00:00 0 {path}\n" for path in maps
|
||||
))
|
||||
parent_children = root / str(ppid) / "task" / str(ppid) / "children"
|
||||
if parent_children.is_file():
|
||||
existing = parent_children.read_text().split()
|
||||
if str(pid) not in existing:
|
||||
parent_children.write_text(" ".join(existing + [str(pid)]) + "\n")
|
||||
return process
|
||||
|
||||
|
||||
def rows(text):
|
||||
prefix = MODULE["PREFIX"]
|
||||
return [json.loads(line.removeprefix(prefix)) for line in text.splitlines() if line.startswith(prefix)]
|
||||
|
||||
|
||||
class DiagnosticInventoryTest(unittest.TestCase):
|
||||
def test_default_off_produces_no_output(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = io.StringIO()
|
||||
result = MODULE["main"](
|
||||
["--once", "--root-pid", "100", "--proc-root", directory], {}, output,
|
||||
)
|
||||
self.assertEqual(result, 0)
|
||||
self.assertEqual(output.getvalue(), "")
|
||||
|
||||
def test_once_scopes_processes_and_reports_only_safe_graphics_identity(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
proc = Path(directory)
|
||||
write_process(proc, 100, 1, 10, "droiddeck-session", "/usr/local/bin/droiddeck-session steam", [
|
||||
"/usr/lib/libvulkan.so.1",
|
||||
])
|
||||
write_process(proc, 101, 100, 11, "steam", "/secret/account/steam -login private-token", [
|
||||
"/data/user/0/com.droiddeck.launcher/files/components/system-vulkan/abc/libVkLayer_hybris_compat.so",
|
||||
"/data/user/0/com.droiddeck.launcher/files/components/system-vulkan/abc/libvulkan-private-account.so",
|
||||
"/data/user/0/com.droiddeck.launcher/files/components/system-vulkan/abc/libdrm_accountSecret.so",
|
||||
"/data/user/0/com.droiddeck.launcher/files/components/system-vulkan/abc/libxcb-sessionSecret.so",
|
||||
"/data/user/0/com.droiddeck.launcher/files/components/system-vulkan/abc/libvkd3d-userSecret.so",
|
||||
"/data/user/0/com.droiddeck.launcher/files/components/system-vulkan/abc/libdrm_amdgpu.so.1.0.0",
|
||||
"/home/private-user/not-graphics-secret.so",
|
||||
"/home/private-user/libvulkan-private-user.so",
|
||||
])
|
||||
write_process(proc, 102, 101, 12, "steamwebhelper", "steamwebhelper --user-data-dir=/secret/profile", [
|
||||
"/vendor/lib64/hw/vulkan.samsung.so",
|
||||
"/tmp/libvulkan-secret with spaces.so",
|
||||
])
|
||||
write_process(proc, 103, 101, 13, "private-game-title", "private-game-title --token=child-secret", [])
|
||||
write_process(proc, 200, 1, 20, "unrelated", "unrelated --password=hunter2", [
|
||||
"/vendor/lib64/hw/vulkan.unrelated.so",
|
||||
])
|
||||
output = io.StringIO()
|
||||
MODULE["main"](
|
||||
["--once", "--root-pid", "100", "--proc-root", directory],
|
||||
{"DROIDDECK_ENHANCED_DIAGNOSTICS": "1"}, output,
|
||||
)
|
||||
payloads = rows(output.getvalue())
|
||||
self.assertEqual(payloads[0]["event"], "provider")
|
||||
snapshot = payloads[1]
|
||||
self.assertEqual(snapshot["alive"], 4)
|
||||
self.assertEqual(snapshot["roles"], {
|
||||
"game-or-helper": 1, "session": 1, "steam-client": 1, "steam-webhelper": 1,
|
||||
})
|
||||
self.assertEqual(snapshot["providerStatus"], "available")
|
||||
self.assertEqual(snapshot["scopeMethod"], "children")
|
||||
self.assertFalse(snapshot["scopeIncomplete"])
|
||||
libraries = {(item["name"], item["origin"]) for item in snapshot["libraries"]}
|
||||
self.assertIn(("libvulkan.so.1", "guest-runtime"), libraries)
|
||||
self.assertIn(("libVkLayer_hybris_compat.so", "droiddeck-component"), libraries)
|
||||
self.assertIn(("libdrm_amdgpu.so.1.0.0", "droiddeck-component"), libraries)
|
||||
self.assertIn(("vulkan.samsung.so", "android-vendor"), libraries)
|
||||
self.assertTrue(any(name.startswith("sha256:") and origin == "other" for name, origin in libraries))
|
||||
self.assertGreaterEqual(sum(name.startswith("sha256:") and origin == "droiddeck-component"
|
||||
for name, origin in libraries), 4)
|
||||
text = output.getvalue()
|
||||
for secret in ("private-token", "private-user", "child-secret", "private-game-title", "hunter2",
|
||||
"accountSecret", "sessionSecret", "userSecret", "unrelated", "/vendor/", "/data/user/"):
|
||||
self.assertNotIn(secret, text)
|
||||
|
||||
def test_missing_maps_and_proc_are_explicit(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
proc = Path(directory)
|
||||
write_process(proc, 100, 1, 10, "droiddeck-session", "droiddeck-session")
|
||||
collector = MODULE["Collector"](proc, 100)
|
||||
snapshot, _ = collector.scan()
|
||||
self.assertEqual(snapshot["maps"]["unavailable"], 1)
|
||||
self.assertEqual(snapshot["providerStatus"], "available")
|
||||
missing = MODULE["Collector"](proc / "missing", 100)
|
||||
snapshot, _ = missing.scan()
|
||||
self.assertEqual(snapshot["providerStatus"], "proc-unavailable")
|
||||
|
||||
def test_children_fallback_is_explicitly_incomplete(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
proc = Path(directory)
|
||||
process = write_process(proc, 100, 1, 10, "droiddeck-session", "droiddeck-session", [])
|
||||
write_process(proc, 101, 100, 11, "steam", "steam", [])
|
||||
(process / "task" / "100" / "children").unlink()
|
||||
collector = MODULE["Collector"](proc, 100)
|
||||
snapshot, _ = collector.scan()
|
||||
self.assertEqual(snapshot["alive"], 2)
|
||||
self.assertEqual(snapshot["scopeMethod"], "children+scan-fallback")
|
||||
self.assertTrue(snapshot["scopeIncomplete"])
|
||||
|
||||
def test_pid_reuse_is_an_exit_and_a_new_process(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
proc = Path(directory)
|
||||
write_process(proc, 99, 1, 9, "droiddeck-session", "droiddeck-session", [])
|
||||
process = write_process(proc, 100, 99, 10, "steam", "steam", [])
|
||||
collector = MODULE["Collector"](proc, 99)
|
||||
first, _ = collector.scan()
|
||||
first_token = next(row["token"] for row in first["started"] if row["role"] == "steam-client")
|
||||
fields = ["S", "99"] + ["0"] * 17 + ["20"]
|
||||
(process / "stat").write_text("100 (steam) " + " ".join(fields) + "\n")
|
||||
second, changed = collector.scan()
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(next(row["token"] for row in second["exited"] if row["role"] == "steam-client"), first_token)
|
||||
self.assertNotEqual(
|
||||
next(row["token"] for row in second["started"] if row["role"] == "steam-client"), first_token,
|
||||
)
|
||||
|
||||
def test_exec_role_change_is_reported_without_pid_reuse(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
proc = Path(directory)
|
||||
process = write_process(proc, 100, 1, 10, "droiddeck-session", "droiddeck-session", [])
|
||||
collector = MODULE["Collector"](proc, 100)
|
||||
collector.scan()
|
||||
(process / "comm").write_text("gamescope\n")
|
||||
(process / "cmdline").write_bytes(b"gamescope\0--backend\0wayland\0")
|
||||
snapshot, changed = collector.scan()
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual(snapshot["roles"], {"gamescope": 1})
|
||||
|
||||
def test_process_scope_is_bounded(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
proc = Path(directory)
|
||||
write_process(proc, 100, 1, 10, "droiddeck-session", "droiddeck-session", [])
|
||||
for pid in range(101, 370):
|
||||
write_process(proc, pid, 100, pid, "helper", "helper", [])
|
||||
collector = MODULE["Collector"](proc, 100)
|
||||
snapshot, _ = collector.scan()
|
||||
self.assertLessEqual(snapshot["alive"], MODULE["MAX_PROCESSES"])
|
||||
self.assertTrue(snapshot["scopeCapped"])
|
||||
|
||||
def test_reused_root_pid_is_not_adopted(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
proc = Path(directory)
|
||||
process = write_process(proc, 100, 1, 10, "droiddeck-session", "droiddeck-session", [])
|
||||
collector = MODULE["Collector"](proc, 100)
|
||||
collector.scan()
|
||||
fields = ["S", "1"] + ["0"] * 17 + ["20"]
|
||||
(process / "stat").write_text("100 (unrelated-app) " + " ".join(fields) + "\n")
|
||||
snapshot, _ = collector.scan()
|
||||
self.assertEqual(snapshot["providerStatus"], "root-reused")
|
||||
self.assertEqual(snapshot["alive"], 0)
|
||||
self.assertNotIn("unrelated-app", json.dumps(snapshot))
|
||||
|
||||
def test_reparented_child_is_rejected_after_process_reads(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
proc = Path(directory)
|
||||
write_process(proc, 100, 1, 10, "droiddeck-session", "droiddeck-session", [])
|
||||
child = write_process(proc, 101, 100, 11, "steam", "steam", [])
|
||||
globals_ = MODULE["Collector"].scan.__globals__
|
||||
original = globals_["process_stat"]
|
||||
calls = 0
|
||||
|
||||
def racing(path):
|
||||
nonlocal calls
|
||||
if path == child / "stat":
|
||||
calls += 1
|
||||
if calls == 3:
|
||||
fields = ["S", "1"] + ["0"] * 17 + ["11"]
|
||||
path.write_text("101 (steam) " + " ".join(fields) + "\n")
|
||||
return original(path)
|
||||
|
||||
globals_["process_stat"] = racing
|
||||
try:
|
||||
collector = MODULE["Collector"](proc, 100)
|
||||
snapshot, _ = collector.scan()
|
||||
finally:
|
||||
globals_["process_stat"] = original
|
||||
self.assertEqual(snapshot["roles"], {"session": 1})
|
||||
self.assertEqual(snapshot["alive"], 1)
|
||||
|
||||
def test_watch_is_bounded_and_emits_heartbeats_without_process_changes(self):
|
||||
globals_ = MODULE["main"].__globals__
|
||||
original = globals_["HEARTBEAT_MS"]
|
||||
globals_["HEARTBEAT_MS"] = 40
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
proc = Path(directory)
|
||||
write_process(proc, 100, 1, 10, "droiddeck-session", "droiddeck-session", [])
|
||||
output = io.StringIO()
|
||||
MODULE["main"](
|
||||
["--watch", "--root-pid", "100", "--proc-root", directory,
|
||||
"--interval-ms", "250", "--duration-ms", "520", "--max-records", "4"],
|
||||
{"DROIDDECK_ENHANCED_DIAGNOSTICS": "1"}, output,
|
||||
)
|
||||
payloads = rows(output.getvalue())
|
||||
self.assertEqual(payloads[0]["event"], "provider")
|
||||
self.assertEqual(payloads[1]["event"], "snapshot")
|
||||
self.assertEqual(payloads[2]["event"], "heartbeat")
|
||||
self.assertNotIn("libraries", payloads[2])
|
||||
self.assertTrue(payloads[2]["librariesUnchanged"])
|
||||
self.assertLessEqual(len(payloads), 4)
|
||||
finally:
|
||||
globals_["HEARTBEAT_MS"] = original
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,50 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SESSION = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/droiddeck-session'
|
||||
|
||||
|
||||
class DiagnosticSessionIntegrationTest(unittest.TestCase):
|
||||
def run_inventory(self, enabled, inside=False, installed=True):
|
||||
source = SESSION.read_text()
|
||||
match = re.search(r'^start_diagnostic_inventory\(\) \{\n.*?^\}\n', source, re.M | re.S)
|
||||
self.assertIsNotNone(match)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
marker = root / 'called'
|
||||
if installed:
|
||||
helper = root / 'droiddeck-diagnostic-inventory'
|
||||
helper.write_text('#!/bin/bash\nprintf "%s\\n" "$@" > "$MARKER"\n')
|
||||
helper.chmod(0o755)
|
||||
env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'],
|
||||
MARKER=str(marker), DROIDDECK_ENHANCED_DIAGNOSTICS='1' if enabled else '0')
|
||||
env['BL_INSIDE'] = '1' if inside else ''
|
||||
script = match.group() + '\nstart_diagnostic_inventory\n'
|
||||
if installed and enabled and not inside:
|
||||
script += 'wait "$_bl_diagnostic_inventory"\n'
|
||||
result = subprocess.run(['bash', '-c', script], env=env, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
return marker.read_text().splitlines() if marker.exists() else [], result.stdout
|
||||
|
||||
def test_starts_for_an_enhanced_outer_session(self):
|
||||
arguments, _ = self.run_inventory(True)
|
||||
self.assertEqual(arguments[:2], ['--watch', '--root-pid'])
|
||||
self.assertTrue(arguments[2].isdigit())
|
||||
|
||||
def test_does_not_start_when_disabled_or_inside_gamescope(self):
|
||||
self.assertEqual(self.run_inventory(False)[0], [])
|
||||
self.assertEqual(self.run_inventory(True, inside=True)[0], [])
|
||||
|
||||
def test_reports_when_the_provider_is_not_installed(self):
|
||||
_, output = self.run_inventory(True, installed=False)
|
||||
self.assertIn('DROIDDECK_PROCESS_TRACE', output)
|
||||
self.assertIn('"status":"unavailable"', output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,186 @@
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
import unittest
|
||||
|
||||
|
||||
BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin"
|
||||
GAME_ENV = BIN / "droiddeck-game-env"
|
||||
MODULE = runpy.run_path(str(GAME_ENV))
|
||||
|
||||
|
||||
class GameExitDiagnosticsTest(unittest.TestCase):
|
||||
def run_child(self, body, app_id="42"):
|
||||
with contextlib.redirect_stderr(io.StringIO()) as said:
|
||||
status = MODULE["start"](
|
||||
[sys.executable, "-c", body], os.environ.copy(), None,
|
||||
diagnostic_app_id=app_id,
|
||||
)
|
||||
return status, said.getvalue()
|
||||
|
||||
def test_clean_and_nonzero_exits_are_authoritative(self):
|
||||
for child_status in (0, 7):
|
||||
with self.subTest(status=child_status):
|
||||
status, said = self.run_child("import sys; sys.exit(%d)" % child_status)
|
||||
self.assertEqual(status, child_status)
|
||||
self.assertIn("event=game_start app_id=42", said)
|
||||
self.assertIn(
|
||||
"event=game_exit app_id=42 raw_status=%d normalized_status=%d wrapper_status=%d signal=0" %
|
||||
(child_status, child_status, child_status), said,
|
||||
)
|
||||
|
||||
def test_signal_exit_is_normalized_and_records_the_signal(self):
|
||||
status, said = self.run_child("import os, signal; os.kill(os.getpid(), signal.SIGTERM)")
|
||||
self.assertEqual(status, 128 + signal.SIGTERM)
|
||||
self.assertIn(
|
||||
"event=game_exit app_id=42 raw_status=-15 normalized_status=143 wrapper_status=143 signal=15", said,
|
||||
)
|
||||
|
||||
def test_xbox_exit_is_recorded_without_inventing_a_start(self):
|
||||
with contextlib.redirect_stderr(io.StringIO()) as said:
|
||||
MODULE["diagnostic_game_exit"]("42", -signal.SIGTERM, "xbox", 241)
|
||||
self.assertNotIn("event=game_start", said.getvalue())
|
||||
self.assertIn(
|
||||
"event=game_exit app_id=42 raw_status=-15 normalized_status=143 wrapper_status=241 signal=15 source=xbox",
|
||||
said.getvalue(),
|
||||
)
|
||||
|
||||
def test_signal_handler_forwards_to_the_child(self):
|
||||
class Process:
|
||||
def __init__(self):
|
||||
self.forwarded = []
|
||||
|
||||
def send_signal(self, signum):
|
||||
self.forwarded.append(signum)
|
||||
|
||||
def wait(self):
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
return -signal.SIGTERM
|
||||
|
||||
process = Process()
|
||||
self.assertEqual(MODULE["wait_forwarding_signals"](process), 128 + signal.SIGTERM)
|
||||
self.assertEqual(process.forwarded, [signal.SIGTERM])
|
||||
|
||||
def test_default_path_still_execs_in_place_without_diagnostic_output(self):
|
||||
called = []
|
||||
with mock.patch("os.execvpe", side_effect=lambda *args: called.append(args)), \
|
||||
contextlib.redirect_stderr(io.StringIO()) as said:
|
||||
MODULE["start"](["/game", "arg"], {"A": "B"}, None)
|
||||
self.assertEqual(called, [("/game", ["/game", "arg"], {"A": "B"})])
|
||||
self.assertNotIn("DROIDDECK_DIAGNOSTICS", said.getvalue())
|
||||
|
||||
def test_failed_selected_launch_does_not_invent_a_start_or_exit(self):
|
||||
with mock.patch("subprocess.Popen", side_effect=FileNotFoundError("missing")), \
|
||||
contextlib.redirect_stderr(io.StringIO()) as said:
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
MODULE["start"](["/missing"], {}, None, diagnostic_app_id="42")
|
||||
self.assertNotIn("DROIDDECK_DIAGNOSTICS", said.getvalue())
|
||||
|
||||
def test_cleanup_runs_after_a_diagnostic_child(self):
|
||||
cleaned = []
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
status = MODULE["start"](
|
||||
[sys.executable, "-c", "pass"], os.environ.copy(), None,
|
||||
after=lambda: cleaned.append(True), diagnostic_app_id="42",
|
||||
)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(cleaned, [True])
|
||||
|
||||
def test_diagnostic_install_script_keeps_success_recording(self):
|
||||
recorded = []
|
||||
with mock.patch.dict(MODULE["start"].__globals__, record_install_scripts=lambda digest, env: recorded.append((digest, env))), \
|
||||
contextlib.redirect_stderr(io.StringIO()):
|
||||
status = MODULE["start"](
|
||||
[sys.executable, "-c", "pass"], {"CUSTOM": "kept"}, "digest",
|
||||
diagnostic_app_id="42",
|
||||
)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(recorded, [("digest", {"CUSTOM": "kept"})])
|
||||
|
||||
def test_cleanup_path_keeps_the_original_install_recording_precedence(self):
|
||||
recorded = []
|
||||
with mock.patch.dict(MODULE["start"].__globals__, record_install_scripts=lambda digest, env: recorded.append((digest, env))):
|
||||
status = MODULE["start"](
|
||||
[sys.executable, "-c", "pass"], {"CUSTOM": "kept"}, "digest",
|
||||
after=lambda: None,
|
||||
)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(recorded, [])
|
||||
|
||||
def test_main_only_traces_valid_waitforexitandrun_games(self):
|
||||
with tempfile.TemporaryDirectory(prefix="game-exit-diagnostics-") as directory:
|
||||
root = Path(directory)
|
||||
probe = root / "probe"
|
||||
probe.write_text("#!" + sys.executable + "\nimport sys\nsys.exit(7)\n")
|
||||
probe.chmod(0o755)
|
||||
base = dict(os.environ, HOME=directory, PATH=os.defpath,
|
||||
DROIDDECK_ENHANCED_DIAGNOSTICS="1")
|
||||
valid = subprocess.run(
|
||||
[sys.executable, str(GAME_ENV), str(probe), "waitforexitandrun"],
|
||||
env={**base, "STEAM_COMPAT_DATA_PATH": "/compatdata/42"},
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
self.assertEqual(valid.returncode, 7, valid.stderr)
|
||||
self.assertIn("event=game_start app_id=42", valid.stderr)
|
||||
self.assertIn(
|
||||
"event=game_exit app_id=42 raw_status=7 normalized_status=7 wrapper_status=7 signal=0", valid.stderr,
|
||||
)
|
||||
|
||||
for verb, prefix in (("run", "/compatdata/42"), ("waitforexitandrun", "/compatdata/not-a-game")):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(GAME_ENV), str(probe), verb],
|
||||
env={**base, "STEAM_COMPAT_DATA_PATH": prefix},
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
self.assertEqual(result.returncode, 7, result.stderr)
|
||||
self.assertNotIn("DROIDDECK_DIAGNOSTICS", result.stderr)
|
||||
|
||||
def test_main_records_xbox_result_without_changing_it(self):
|
||||
launch = lambda command, env: 23
|
||||
with tempfile.TemporaryDirectory(prefix="xbox-exit-diagnostics-") as directory, \
|
||||
mock.patch.dict(os.environ, {
|
||||
"HOME": directory,
|
||||
"PATH": os.defpath,
|
||||
"STEAM_COMPAT_DATA_PATH": "/compatdata/42",
|
||||
"DROIDDECK_ENHANCED_DIAGNOSTICS": "1",
|
||||
}, clear=True), \
|
||||
mock.patch("sys.argv", ["droiddeck-game-env", "/proton", "waitforexitandrun", "game.exe"]), \
|
||||
mock.patch("runpy.run_path", return_value={"launch": launch}), \
|
||||
contextlib.redirect_stderr(io.StringIO()) as said:
|
||||
status = MODULE["main"]()
|
||||
self.assertEqual(status, 23)
|
||||
self.assertNotIn("event=game_start", said.getvalue())
|
||||
self.assertIn(
|
||||
"event=game_exit app_id=42 raw_status=23 normalized_status=23 wrapper_status=23 signal=0 source=xbox",
|
||||
said.getvalue(),
|
||||
)
|
||||
|
||||
def test_main_preserves_negative_xbox_result_and_reports_wrapper_status(self):
|
||||
launch = lambda command, env: -signal.SIGTERM
|
||||
with tempfile.TemporaryDirectory(prefix="xbox-signal-diagnostics-") as directory, \
|
||||
mock.patch.dict(os.environ, {
|
||||
"HOME": directory,
|
||||
"PATH": os.defpath,
|
||||
"STEAM_COMPAT_DATA_PATH": "/compatdata/42",
|
||||
"DROIDDECK_ENHANCED_DIAGNOSTICS": "1",
|
||||
}, clear=True), \
|
||||
mock.patch("sys.argv", ["droiddeck-game-env", "/proton", "waitforexitandrun", "game.exe"]), \
|
||||
mock.patch("runpy.run_path", return_value={"launch": launch}), \
|
||||
contextlib.redirect_stderr(io.StringIO()) as said:
|
||||
status = MODULE["main"]()
|
||||
self.assertEqual(status, -signal.SIGTERM)
|
||||
self.assertIn(
|
||||
"event=game_exit app_id=42 raw_status=-15 normalized_status=143 wrapper_status=241 signal=15 source=xbox",
|
||||
said.getvalue(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,133 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PATCH = ROOT / "tools/gamescope/patches/0900-enhanced-frame-diagnostics.patch"
|
||||
|
||||
|
||||
def added_file(patch, name):
|
||||
lines = patch.read_text().splitlines()
|
||||
marker = f"diff --git a/{name} b/{name}"
|
||||
start = lines.index(marker)
|
||||
output = []
|
||||
in_hunk = False
|
||||
for line in lines[start + 1:]:
|
||||
if line.startswith("diff --git "):
|
||||
break
|
||||
if line.startswith("@@"):
|
||||
in_hunk = True
|
||||
elif in_hunk and line.startswith("+") and not line.startswith("+++"):
|
||||
output.append(line[1:])
|
||||
return "\n".join(output) + "\n"
|
||||
|
||||
|
||||
class GamescopeDiagnosticsTest(unittest.TestCase):
|
||||
def test_patch_covers_each_frame_boundary_without_payload_logging(self):
|
||||
text = PATCH.read_text()
|
||||
for path in ("src/wlserver.cpp", "src/steamcompmgr.cpp", "src/Backends/WaylandBackend.cpp"):
|
||||
self.assertIn(f"diff --git a/{path} b/{path}", text)
|
||||
self.assertIn("diff --git a/src/main.cpp b/src/main.cpp", text)
|
||||
self.assertIn("droiddeck::frame_trace::Start();", text)
|
||||
self.assertIn("droiddeck::frame_trace::Tick();", text)
|
||||
self.assertIn("pTraceFocus->focusWindow == w", text)
|
||||
self.assertNotIn("GetFocusForWindow( w ) != nullptr", text)
|
||||
for event in (
|
||||
"X11Damage", "X11DamageCurrent", "X11DamageOther", "SurfaceCommit",
|
||||
"SurfaceCommitBuffer", "SurfaceCommitNoBuffer", "CommitDequeued",
|
||||
"CommitDequeuedCurrent", "CommitDequeuedOther", "ImportSucceeded",
|
||||
"ImportSucceededCurrent", "ImportSucceededOther", "ImportFailed",
|
||||
"ImportFailedCurrent", "ImportFailedOther", "FenceReady", "FenceWaited",
|
||||
"CommitDone", "CommitDoneCurrent", "CommitDoneOther", "BaseLatched",
|
||||
"BaseLatchedCurrent", "BaseLatchedOther",
|
||||
"OutputPresent", "OutputAttach", "OutputDetach",
|
||||
"OutputCommit", "OutputRelease",
|
||||
):
|
||||
self.assertIn(f"Event::{event}", text)
|
||||
header = added_file(PATCH, "src/droiddeck_frame_trace.hpp")
|
||||
self.assertIn('std::getenv( "DROIDDECK_ENHANCED_DIAGNOSTICS" )', header)
|
||||
self.assertIn("DROIDDECK_FRAME_TRACE_FAST_INTERVAL_MS 2000", header)
|
||||
self.assertIn("DROIDDECK_FRAME_TRACE_SLOW_INTERVAL_MS 15000", header)
|
||||
self.assertIn("DROIDDECK_FRAME_TRACE_DURATION_MS 86400000", header)
|
||||
self.assertNotRegex(header, re.compile(r"title|pixel|address|argv", re.I))
|
||||
|
||||
def test_collector_is_default_off_bounded_and_thread_safe(self):
|
||||
compiler = shutil.which(os.environ.get("CXX", "c++"))
|
||||
if not compiler:
|
||||
self.skipTest("a C++ compiler is required")
|
||||
with tempfile.TemporaryDirectory(prefix="gamescope-frame-trace-") as directory:
|
||||
work = Path(directory)
|
||||
(work / "droiddeck_frame_trace.hpp").write_text(
|
||||
added_file(PATCH, "src/droiddeck_frame_trace.hpp"))
|
||||
(work / "trace.cpp").write_text(r'''
|
||||
#include "droiddeck_frame_trace.hpp"
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
int main() {
|
||||
droiddeck::frame_trace::Start();
|
||||
std::vector<std::thread> threads;
|
||||
for (int i = 0; i < 4; ++i)
|
||||
threads.emplace_back([] { for (int n = 0; n < 1000; ++n)
|
||||
droiddeck::frame_trace::RecordWindow(
|
||||
droiddeck::frame_trace::Event::X11Damage,
|
||||
droiddeck::frame_trace::Event::X11DamageCurrent,
|
||||
droiddeck::frame_trace::Event::X11DamageOther,
|
||||
false); });
|
||||
for (auto &thread : threads) thread.join();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(60));
|
||||
droiddeck::frame_trace::Tick();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(70));
|
||||
droiddeck::frame_trace::Tick();
|
||||
droiddeck::frame_trace::Record(droiddeck::frame_trace::Event::SurfaceCommit);
|
||||
droiddeck::frame_trace::RecordWindow(
|
||||
droiddeck::frame_trace::Event::ImportSucceeded,
|
||||
droiddeck::frame_trace::Event::ImportSucceededCurrent,
|
||||
droiddeck::frame_trace::Event::ImportSucceededOther,
|
||||
true);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(70));
|
||||
droiddeck::frame_trace::Tick();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(120));
|
||||
droiddeck::frame_trace::Tick();
|
||||
droiddeck::frame_trace::Record(droiddeck::frame_trace::Event::OutputPresent);
|
||||
droiddeck::frame_trace::Tick();
|
||||
}
|
||||
''')
|
||||
program = work / "trace"
|
||||
built = subprocess.run([
|
||||
compiler, "-std=c++20", "-Wall", "-Wextra", "-Werror", "-pthread",
|
||||
"-DDROIDDECK_FRAME_TRACE_FAST_INTERVAL_MS=50",
|
||||
"-DDROIDDECK_FRAME_TRACE_SLOW_INTERVAL_MS=60",
|
||||
"-DDROIDDECK_FRAME_TRACE_FAST_DURATION_MS=100",
|
||||
"-DDROIDDECK_FRAME_TRACE_DURATION_MS=300",
|
||||
str(work / "trace.cpp"), "-o", str(program),
|
||||
], capture_output=True, text=True)
|
||||
self.assertEqual(0, built.returncode, built.stdout + built.stderr)
|
||||
quiet = subprocess.run([str(program)], capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(0, quiet.returncode, quiet.stdout + quiet.stderr)
|
||||
self.assertEqual("", quiet.stderr)
|
||||
env = os.environ.copy()
|
||||
env["DROIDDECK_ENHANCED_DIAGNOSTICS"] = "1"
|
||||
traced = subprocess.run([str(program)], env=env, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(0, traced.returncode, traced.stdout + traced.stderr)
|
||||
self.assertEqual(1, traced.stderr.count("schema=1"))
|
||||
summaries = [line for line in traced.stderr.splitlines() if "t_ms=" in line]
|
||||
self.assertIn("t_ms=0 x11_damage=0", summaries[0])
|
||||
self.assertTrue(any("x11_damage=4000" in line and "x11_damage_current=0" in line and
|
||||
"x11_damage_other=4000" in line and "surface_commit=0" in line
|
||||
for line in summaries[1:]))
|
||||
self.assertTrue(any("surface_commit=1" in line for line in summaries[1:]))
|
||||
self.assertTrue(any("import_ok=1" in line and "import_ok_current=1" in line and
|
||||
"import_ok_other=0" in line for line in summaries[1:]))
|
||||
self.assertIn("t_ms=300", summaries[-1])
|
||||
self.assertIn("output_present=0", summaries[-1])
|
||||
self.assertLessEqual(len(summaries), 5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -11,7 +11,7 @@ SESSION = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/d
|
||||
|
||||
class SteamExitStatusTest(unittest.TestCase):
|
||||
def run_session(self, child_status, compositor_status=0, report=True, report_value=None,
|
||||
stale_status=None):
|
||||
stale_status=None, diagnostics=False, output=False):
|
||||
source = SESSION.read_text()
|
||||
function = re.search(r'^run_gamescope\(\) \(\n.*?^\)\n', source, re.M | re.S)
|
||||
self.assertIsNotNone(function)
|
||||
@@ -31,6 +31,7 @@ class SteamExitStatusTest(unittest.TestCase):
|
||||
child.chmod(0o755)
|
||||
env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'],
|
||||
XDG_RUNTIME_DIR=str(root))
|
||||
env['DROIDDECK_ENHANCED_DIAGNOSTICS'] = '1' if diagnostics else '0'
|
||||
result = subprocess.run(['bash', '-c', function.group() + '\nrun_gamescope "$@"',
|
||||
'session', str(child), str(child_status),
|
||||
str(report_value) if report_value is not None else ''], env=env,
|
||||
@@ -38,7 +39,7 @@ class SteamExitStatusTest(unittest.TestCase):
|
||||
self.assertEqual(list(root.glob('droiddeck-steam-exit.??????')), [])
|
||||
if stale_status is not None:
|
||||
self.assertEqual(stale_file.read_text(), str(stale_status) + '\n')
|
||||
return result.returncode
|
||||
return result if output else result.returncode
|
||||
|
||||
def test_steam_crash_survives_successful_compositor_shutdown(self):
|
||||
self.assertEqual(self.run_session(139), 139)
|
||||
@@ -64,6 +65,21 @@ class SteamExitStatusTest(unittest.TestCase):
|
||||
def test_stale_marker_does_not_affect_a_new_launch(self):
|
||||
self.assertEqual(self.run_session(0, report=False, stale_status=139), 0)
|
||||
|
||||
def test_diagnostics_preserve_separate_steam_and_compositor_statuses(self):
|
||||
result = self.run_session(139, diagnostics=True, output=True)
|
||||
self.assertEqual(result.returncode, 139)
|
||||
self.assertIn('event=session_exit compositor_status=0 steam_status=139', result.stdout)
|
||||
|
||||
def test_diagnostics_report_an_unavailable_child_status(self):
|
||||
result = self.run_session(0, compositor_status=1, report=False, diagnostics=True, output=True)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn('event=session_exit compositor_status=1 steam_status=unavailable', result.stdout)
|
||||
|
||||
def test_diagnostics_are_disabled_by_default(self):
|
||||
result = self.run_session(0, output=True)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertNotIn('DROIDDECK_DIAGNOSTICS', result.stdout)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user