Add enhanced one-session diagnostic bundles

This commit is contained in:
wallentx authored and Kurt Himebauch committed 2026-11-05 13:32:46 -05:00
1 parent b563b8d1eb
commit 5614bd5d5a
32 files changed
+3100 -141

No files matched your search

+1
View File
@@ -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),
+7
View File
@@ -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))
+42
View File
@@ -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
+234
View File
@@ -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()
+186
View File
@@ -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()
+133
View File
@@ -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()
+18 -2
View File
@@ -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()