Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 43 additions & 13 deletions maestro-client/src/main/java/maestro/debuglog/DebugLogStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package maestro.debuglog
import maestro.Driver
import maestro.utils.FileUtils
import net.harawata.appdirs.AppDirsFactory
import org.slf4j.LoggerFactory
import java.io.File
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
Expand All @@ -21,6 +22,12 @@ object DebugLogStore {
private const val APP_AUTHOR = "mobile_dev"
private const val LOG_DIR_DATE_FORMAT = "yyyy-MM-dd_HHmmss"
private const val KEEP_LOG_COUNT = 6

// A run never spans this long, so any working directory older than this is an orphan from a
// crashed run and is safe to reap without touching a concurrently-running process's live dir.
private const val ORPHAN_DIR_MAX_AGE_MILLIS = 24L * 60 * 60 * 1000

private val LOGGER = LoggerFactory.getLogger(DebugLogStore::class.java)
val logDirectory = File(AppDirsFactory.getInstance().getUserLogDir(APP_NAME, null, APP_AUTHOR))

private val currentRunLogDirectory: File
Expand Down Expand Up @@ -88,26 +95,27 @@ object DebugLogStore {

fun finalizeRun() {
fileHandler.close()
val output = File(currentRunLogDirectory.parent, "${currentRunLogDirectory.name}.zip")
FileUtils.zipDir(currentRunLogDirectory.toPath(), output.toPath())
currentRunLogDirectory.deleteRecursively()
// Archiving debug logs is best-effort cleanup; it must never crash a run that has already
// completed (e.g. if the log directory was reaped by a concurrent process). See issue #1522.
try {
val output = File(currentRunLogDirectory.parent, "${currentRunLogDirectory.name}.zip")
FileUtils.zipDir(currentRunLogDirectory.toPath(), output.toPath())
currentRunLogDirectory.deleteRecursively()
} catch (e: Exception) {
LOGGER.warn("Failed to archive debug logs for this run; continuing", e)
}
}

private fun logFile(named: String): File {
return File(currentRunLogDirectory, "$named.log")
}

private fun removeOldLogs(baseDir: File) {
if (!baseDir.isDirectory) {
return
}

val existing = baseDir.listFiles() ?: return
val toDelete = existing.sortedByDescending { it.name }
.drop(KEEP_LOG_COUNT)
.toList()

toDelete.forEach { it.deleteRecursively() }
pruneLogs(
baseDir = baseDir,
keepZipCount = KEEP_LOG_COUNT,
orphanDirCutoffMillis = System.currentTimeMillis() - ORPHAN_DIR_MAX_AGE_MILLIS,
)
}

fun logSystemInfo() {
Expand All @@ -134,6 +142,28 @@ object DebugLogStore {
}
}

/**
* Prunes the debug log directory without ever deleting a directory that may still be in use by a
* concurrently-running Maestro process.
*
* - Completed runs are stored as `.zip` archives; only the newest [keepZipCount] are retained.
* - Working directories are owned and deleted by their own run on completion. Any directory left
* behind is an orphan from a crashed run and is reaped only when older than [orphanDirCutoffMillis],
* so a live run's freshly-modified directory is never removed.
*/
internal fun pruneLogs(baseDir: File, keepZipCount: Int, orphanDirCutoffMillis: Long) {
if (!baseDir.isDirectory) return
val entries = baseDir.listFiles() ?: return

entries.filter { it.isFile && it.extension == "zip" }
.sortedByDescending { it.name }
.drop(keepZipCount)
.forEach { it.delete() }

entries.filter { it.isDirectory && it.lastModified() < orphanDirCutoffMillis }
.forEach { it.deleteRecursively() }
}

fun Logger.warn(message: String, throwable: Throwable? = null) {
if (throwable != null) {
log(Level.WARNING, message, throwable)
Expand Down
9 changes: 8 additions & 1 deletion maestro-client/src/main/java/maestro/utils/FileUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,23 @@ import kotlin.io.path.createDirectories
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.streams.toList
import org.slf4j.LoggerFactory

object FileUtils {

private val LOGGER = LoggerFactory.getLogger(FileUtils::class.java)

/**
* Zips directory
*
* @param from dir to zip
* @param to output zip file
*/
fun zipDir(from: Path, to: Path) {
if (!from.exists()) {
LOGGER.warn("Skipping zip: source directory does not exist: {}", from)
return
}
val stream = to.toFile().outputStream()
val files = Files.walk(from).filter { !it.isDirectory() }.toList()
ZipOutputStream(stream).use { zs ->
Expand All @@ -34,7 +41,7 @@ object FileUtils {
zs.closeEntry()
}
} catch (e: IOException) {
e.printStackTrace()
LOGGER.warn("Failed while zipping {} -> {}", from, to, e)
}
}
}
Expand Down
53 changes: 53 additions & 0 deletions maestro-client/src/test/java/maestro/debuglog/DebugLogStoreTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package maestro.debuglog

import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
import java.nio.file.Path

internal class DebugLogStoreTest {

@Test
internal fun `pruneLogs keeps the newest zip archives and deletes older ones`(@TempDir tempDir: Path) {
// Given more finalized run archives than the keep count
val baseDir = tempDir.toFile()
val zips = (1..8).map { File(baseDir, "2026-06-0${it}_100000_111.zip").apply { writeText("z") } }

// When pruning, keeping the newest 6
pruneLogs(baseDir, keepZipCount = 6, orphanDirCutoffMillis = 0)

// Then only the 2 oldest archives are removed
assertThat(zips[0].exists()).isFalse()
assertThat(zips[1].exists()).isFalse()
zips.drop(2).forEach { assertThat(it.exists()).isTrue() }
}

@Test
internal fun `pruneLogs deletes orphaned working dirs older than cutoff but keeps recent ones`(@TempDir tempDir: Path) {
// Given a fresh (live) working dir and an old orphaned one
val baseDir = tempDir.toFile()
val freshDir = File(baseDir, "2026-06-02_120000_222").apply { mkdirs(); File(this, "maestro.log").writeText("x") }
val orphanDir = File(baseDir, "2026-05-01_120000_333").apply { mkdirs(); File(this, "maestro.log").writeText("x") }
val cutoff = 1_000_000L
orphanDir.setLastModified(cutoff - 1)
freshDir.setLastModified(cutoff + 1)

// When pruning with that cutoff
pruneLogs(baseDir, keepZipCount = 6, orphanDirCutoffMillis = cutoff)

// Then the orphan is reaped and the live dir is left untouched
assertThat(orphanDir.exists()).isFalse()
assertThat(freshDir.exists()).isTrue()
}

@Test
internal fun `pruneLogs does nothing when base dir does not exist`(@TempDir tempDir: Path) {
val missing = tempDir.resolve("nope").toFile()

// Should not throw
pruneLogs(missing, keepZipCount = 6, orphanDirCutoffMillis = 0)

assertThat(missing.exists()).isFalse()
}
}
41 changes: 41 additions & 0 deletions maestro-client/src/test/java/maestro/utils/FileUtilsTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package maestro.utils

import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
import java.nio.file.Path

internal class FileUtilsTest {

@Test
internal fun `zipDir does not throw or create output when source directory is missing`(@TempDir tempDir: Path) {
// Given a source directory that does not exist
val missingSource = tempDir.resolve("does-not-exist")
val output = tempDir.resolve("out.zip")

// When zipping it
FileUtils.zipDir(missingSource, output)

// Then it neither throws nor leaves an output file behind
assertThat(output.toFile().exists()).isFalse()
}

@Test
internal fun `zipDir zips files in source directory`(@TempDir tempDir: Path) {
// Given a populated source directory
val source = tempDir.resolve("logs").toFile().apply { mkdirs() }
File(source, "maestro.log").writeText("hello")
val output = tempDir.resolve("out.zip")

// When zipping it
FileUtils.zipDir(source.toPath(), output)

// Then the output zip exists and contains the file
val outFile = output.toFile()
assertThat(outFile.exists()).isTrue()
val unzipTarget = tempDir.resolve("unzipped")
FileUtils.unzip(output, unzipTarget)
assertThat(unzipTarget.resolve("maestro.log").toFile().readText()).isEqualTo("hello")
}
}
Loading