diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index 311dc0082..5ef39f6b6 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -17,7 +17,7 @@ class App : Application() { override fun onCreate() { super.onCreate() - runCatching { EnhancedSessionLogShare.cleanupAbandonedStages(this) } + Thread({ runCatching { EnhancedSessionLogShare.cleanupAbandonedStages(this) } }, "diagnostic-stage-cleanup").start() com.droiddeck.launcher.ui.Themes.loadGradient(this) com.droiddeck.launcher.ui.BuildWatermark.install(this) HomeApp.trackRecents(this) diff --git a/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt b/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt index f838e71ac..6f9f36453 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/EnhancedSessionLogShare.kt @@ -51,17 +51,21 @@ internal object EnhancedSessionLogShare { internal class DiagnosticStageLease internal constructor( val directory: File, + private val canonicalPath: String, private val channel: FileChannel, private val lock: FileLock, ) : Closeable { override fun close() { - try { - lock.release() - } finally { + synchronized(stageLifecycleMonitor) { + activeStagePaths.remove(canonicalPath) try { - channel.close() + lock.release() } finally { - directory.deleteRecursively() + try { + channel.close() + } finally { + directory.deleteRecursively() + } } } } @@ -117,6 +121,7 @@ internal object EnhancedSessionLogShare { override fun initialValue(): SecureSourceOpener? = null } private val stageLifecycleMonitor = Any() + private val activeStagePaths = mutableSetOf() internal fun acquireDiagnosticStage(context: Context, outputName: String): DiagnosticStageLease { val cache = context.cacheDir @@ -133,7 +138,9 @@ internal object EnhancedSessionLogShare { try { val lock = channel.tryLock() ?: throw IOException("Could not lock the diagnostic staging directory") - DiagnosticStageLease(stage, channel, lock) + val canonicalPath = stage.canonicalPath + activeStagePaths += canonicalPath + DiagnosticStageLease(stage, canonicalPath, channel, lock) } catch (error: Exception) { channel.close() stage.deleteRecursively() @@ -149,6 +156,7 @@ internal object EnhancedSessionLogShare { cache.listFiles()?.forEach { stage -> if (!stage.isDirectory || Files.isSymbolicLink(stage.toPath()) || stage.canonicalFile.parentFile != canonicalCache || !isDiagnosticStage(stage.name)) return@forEach + if (stage.canonicalPath in activeStagePaths) return@forEach val active = File(stage, ".active") if (Files.isSymbolicLink(active.toPath())) return@forEach val channel = runCatching { diff --git a/app/src/test/java/com/droiddeck/launcher/session/DiagnosticStageLockProbe.java b/app/src/test/java/com/droiddeck/launcher/session/DiagnosticStageLockProbe.java new file mode 100644 index 000000000..138185a29 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/DiagnosticStageLockProbe.java @@ -0,0 +1,19 @@ +package com.droiddeck.launcher.session; + +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +public final class DiagnosticStageLockProbe { + public static void main(String[] args) throws Exception { + try (FileChannel channel = FileChannel.open(Path.of(args[0]), StandardOpenOption.WRITE)) { + FileLock lock = channel.tryLock(); + if (lock == null) { + System.exit(0); + } + lock.release(); + System.exit(1); + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt index 6af039ade..087c1a998 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SessionLogShareZipTest.kt @@ -6,6 +6,7 @@ import java.text.SimpleDateFormat import java.util.UUID import java.util.Date import java.util.Locale +import java.util.concurrent.TimeUnit import java.util.zip.ZipFile import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -34,6 +35,32 @@ class SessionLogShareZipTest { EnhancedSessionLogShare.cleanupAbandonedStages(app) assertFalse(abandoned.exists()) assertTrue(active.directory.isDirectory) + EnhancedSessionLogShare.cleanupAbandonedStages(app) + assertTrue(active.directory.isDirectory) + val probeClassResource = checkNotNull( + DiagnosticStageLockProbe::class.java.getResource("DiagnosticStageLockProbe.class"), + ) + val probeClass = File(probeClassResource.toURI()) + var classPathRoot = checkNotNull(probeClass.parentFile) + repeat(4) { classPathRoot = checkNotNull(classPathRoot.parentFile) } + val classPath = classPathRoot.absolutePath + val javaName = if (checkNotNull(System.getProperty("os.name")).startsWith("Windows")) { + "java.exe" + } else { + "java" + } + val java = File(File(System.getProperty("java.home"), "bin"), javaName) + val probe = ProcessBuilder( + java.absolutePath, + "-cp", + classPath, + DiagnosticStageLockProbe::class.java.name, + File(active.directory, ".active").absolutePath, + ).redirectErrorStream(true).start() + val completed = probe.waitFor(5, TimeUnit.SECONDS) + if (!completed) probe.destroyForcibly() + assertTrue("lock probe timed out", completed) + assertEquals("cleanup released the active stage lock", 0, probe.exitValue()) } finally { active.close() }