From 847c79ea9a9422721e2db92323b382d68634158d Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Fri, 19 Jun 2026 05:02:53 -0700 Subject: [PATCH] ADFA-3945: extract tar.xz via redirected output file (removes reader-thread race) The Termux tar extraction drained the child's stdout on a separate reader thread. On a 2-minute timeout the code calls destroyForcibly(), which closes the pipe; the reader thread's in-flight read() then throws InterruptedIOException on a bare thread with no handler, crashing the app (Sentry APPDEVFORALL-V0). Fix (root cause, not the symptom): redirect the child's combined stdout/stderr to a temp file via ProcessBuilder.redirectOutput(), drop the reader thread entirely, and read the log back after waitFor(). With OS-side redirection there is no concurrent read to interrupt when the pipe is closed, and the process also can't deadlock on a full pipe buffer. destroyForcibly() on timeout is retained; the temp log is deleted after use. This eliminates the entire bug class rather than catching the exception, and makes a future timeout/cancellation change safe (no race to reintroduce). Per Hal's suggestion on ADFA-3945. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../manager/services/IdeArchiveServiceImpl.kt | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt index 36b3b47230..174b8e265c 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt @@ -18,7 +18,6 @@ import java.io.FilterInputStream import java.io.InputStream import java.io.OutputStream import java.util.concurrent.TimeUnit -import kotlin.concurrent.thread import kotlin.system.measureTimeMillis class IdeArchiveServiceImpl( @@ -255,6 +254,21 @@ class IdeArchiveServiceImpl( } } + /** + * Extracts a `.tar.xz` archive by invoking Termux `tar`. + * + * The child process's combined stdout/stderr is redirected to a temporary log file + * ([ProcessBuilder.redirectOutput]) and read back after the process exits, rather than being + * drained by a separate reader thread. This removes the concurrency hazard behind ADFA-3945 + * (Sentry APPDEVFORALL-V0): when a timeout triggers [Process.destroyForcibly] the pipe is + * closed, and a thread blocked in `read()` on it would throw an `InterruptedIOException` on a + * bare thread with no handler, crashing the app. With OS-side redirection there is no in-flight + * read to interrupt, and the process also cannot deadlock on a full pipe buffer. + * + * @param archiveFile the `.tar.xz` to extract. + * @param outputDir the directory to extract into (must exist and be writable). + * @return `true` only if `tar` completed within the timeout with exit code 0. + */ private fun extractTarXzViaTermux(archiveFile: File, outputDir: File): Boolean { if (!archiveFile.exists()) { logger.debug("Archive not found: ${archiveFile.absolutePath}") @@ -263,28 +277,25 @@ class IdeArchiveServiceImpl( logger.debug("Starting Termux tar extraction: ${archiveFile.absolutePath}") + val logFile = File.createTempFile("tarxz-extract", ".log", outputDir) return runCatching { - val output = StringBuilder() var exitCode = -1 val elapsed = measureTimeMillis { val process = ProcessBuilder( "$TERMUX_BIN_PATH/tar", "-xJf", archiveFile.absolutePath, "-C", outputDir.canonicalPath, "--no-same-owner" - ).redirectErrorStream(true).apply { - environment()["PATH"] = TERMUX_BIN_PATH - }.start() - - val reader = thread(name = "tar-xz-extract-output") { - process.inputStream.bufferedReader().useLines { it.forEach(output::appendLine) } - } + ).redirectErrorStream(true) + .redirectOutput(logFile) + .apply { environment()["PATH"] = TERMUX_BIN_PATH } + .start() val completed = process.waitFor(2, TimeUnit.MINUTES) if (!completed) process.destroyForcibly() - reader.join() exitCode = if (completed) process.exitValue() else -1 } + val output = runCatching { logFile.readText() }.getOrDefault("") when (exitCode) { 0 -> { logger.debug("Extraction succeeded in ${elapsed}ms: $output") @@ -298,6 +309,8 @@ class IdeArchiveServiceImpl( }.getOrElse { e -> logger.error("Termux process error: ${e.message}") false + }.also { + logFile.delete() } }