Skip to content

ADFA-3945: Handle reader-thread IOException on tar.xz extraction timeout#1412

Open
fryanpan wants to merge 2 commits into
stagefrom
ADFA-3945-tarxz-reader-thread-ioexception
Open

ADFA-3945: Handle reader-thread IOException on tar.xz extraction timeout#1412
fryanpan wants to merge 2 commits into
stagefrom
ADFA-3945-tarxz-reader-thread-ioexception

Conversation

@fryanpan

@fryanpan fryanpan commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Jira Ticket: https://appdevforall.atlassian.net/browse/ADFA-3945
Sentry Issue: https://appdevforall-inc-9p.sentry.io/issues/APPDEVFORALL-V0

Reproduction Details

IdeArchiveServiceImpl.extractTarXzViaTermux ran Termux tar and drained the child's stdout on a separate reader thread (thread { process.inputStream.bufferedReader().useLines { … } }). 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 → uncaught → app crash.

Stack Trace

InterruptedIOException: read interrupted by close() on another thread
  at libcore.io.Linux.read(Linux.java)
  ... (BufferedInputStream / StreamDecoder / BufferedReader.readLine) ...
  at com.itsaky.androidide.plugins.manager.services.IdeArchiveServiceImpl.extractTarXzViaTermux$lambda$0$0$1(Unknown Source:31)
  at kotlin.concurrent.ThreadsKt$thread$thread$1.run(Unknown Source:2)

(thread: tar-xz-extract-output · device.class: high)

User Steps

User steps leading up to crash, based on Sentry breadcrumbs:

  • Fires at app startup while a bundled component is being extracted (per related events, the NDK-installer plugin org.appdevforall.ndkinstaller.cgp). The extraction is force-killed (timeout/cancel) and the reader thread dies mid-read. (The crashing event itself has no UI breadcrumbs — it's a background reader thread.)

Was able to reproduce in a unit test?

No.
What remains after this fix is the ProcessBuilder/Termux path — extractTarXzViaTermux is a private fun on a final class and execs a hard-coded on-device tar binary ($TERMUX_BIN_PATH/tar) absent on any JVM test host, so start() can't run there. However, Option B removes the concurrency hazard entirely (no reader thread), so the failure class this ticket is about no longer exists to be triggered. Making the remaining exec path regression-testable would need an injectable process/command seam (@VisibleForTesting) — tracked as a follow-up. The change compiles cleanly (:plugin-manager:compileV8DebugKotlin BUILD SUCCESSFUL).

What Was Fixed

Root-cause fix (Hal's suggestion, "Option B"), not a symptom catch. Redirect the child's combined stdout/stderr to a temp file via ProcessBuilder.redirectOutput(logFile) and read it back after waitFor(), dropping the reader thread entirely:

  • With OS-side redirection there is no concurrent read() to be interrupted when destroyForcibly() closes the pipe — the InterruptedIOException-on-a-bare-thread crash cannot occur.
  • It also removes the latent full-pipe-buffer deadlock risk (the original reason a drainer thread existed).
  • destroyForcibly() on timeout is retained; the temp log is deleted after use.
  • Makes a future timeout/cancellation change safe — there's no race to reintroduce.

(Considered and rejected "Option A" — close stdout then destroyForcibly(): on Android, closing a stream under a blocked read is exactly what produces read interrupted by close(), so it wouldn't reliably give a clean EOF and would still need the catch.)

Testing

:plugin-manager:compileV8DebugKotlin → BUILD SUCCESSFUL. No automated runtime test (see above — the remaining exec path isn't JVM-testable; the bug class itself is removed by construction). Branch history was rewritten to a single clean commit implementing Option B.

Follow-up (separate): make the extraction timeout adaptive for slow devices + add progress/cancel UI + a retry path (the user-facing "extraction actually succeeds" improvement), and add an @VisibleForTesting process seam.


Fixes APPDEVFORALL-V0

@fryanpan fryanpan force-pushed the ADFA-3945-tarxz-reader-thread-ioexception branch from 9f4d410 to 1d4c800 Compare June 19, 2026 12:02
@fryanpan

Copy link
Copy Markdown
Contributor Author

⚠️ Heads-up for reviewers: approach changed + history rewritten (force-push).

This PR previously caught the reader-thread IOException (symptom fix). It now implements the root-cause fix (Hal's Option B): redirect tar's output to a temp file via ProcessBuilder.redirectOutput(), drop the reader thread, and read the log after waitFor(). No concurrent read → the InterruptedIOException-on-a-bare-thread crash can't occur; also removes a latent pipe-buffer deadlock. destroyForcibly() on timeout retained.

Rejected Option A (close-then-destroy): on Android, closing a stream under a blocked read is precisely what throws read interrupted by close(), so it wouldn't give a clean EOF.

Branch was rewritten to a single clean commit (1d4c800ee); the description is updated to match. Follow-up (separate): adaptive timeout + progress/retry UX, and an @VisibleForTesting process seam for regression testing.

@fryanpan fryanpan marked this pull request as ready for review June 19, 2026 16:56
…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) <noreply@anthropic.com>
@fryanpan fryanpan force-pushed the ADFA-3945-tarxz-reader-thread-ioexception branch from 1d4c800 to 847c79e Compare June 19, 2026 16:58
@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough
  • Refactored IdeArchiveServiceImpl’s Termux-based .tar.xz extraction to write tar stdout/stderr to a temporary log file instead of draining the process stream on a background thread.
  • Removed the concurrent stream reader that could throw InterruptedIOException when the process was forcibly terminated on timeout, eliminating the crash path by design.
  • Kept the 2-minute timeout and destroyForcibly() behavior; after the process exits, the log file is read synchronously and the temp file is deleted.
  • Added KDoc describing the new log-file redirection flow and the concurrency hazard it avoids.
  • Risk / best-practice note: the implementation still depends on an external Termux tar binary and a file-based handoff, so it is more robust than pipe draining but still relies on correct cleanup of temporary files and on-device process behavior.
  • Risk / best-practice note: no automated runtime regression test was added for the on-device ProcessBuilder/Termux path, so validation remains limited outside device execution.

Walkthrough

IdeArchiveServiceImpl.extractTarXzViaTermux now redirects the Termux tar process output to a temporary log file, reads that log after completion, and deletes the file in cleanup. The helper’s KDoc was updated, and elapsed time is measured around the process run.

Changes

Termux tar extraction output redirection

Layer / File(s) Summary
File-redirection execution and cleanup
plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt
kotlin.concurrent.thread is removed in favor of kotlin.system.measureTimeMillis; KDoc documents the log-file redirection flow; extractTarXzViaTermux redirects combined stdout/stderr to a temp file, reads the log after waiting for exit, and deletes the temp file afterward.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

Suggested reviewers

  • davidschachterADFA

Poem

🐇 The tar made noise, then softly slept,
A log-file trail was safely kept.
No thread to chase the stream at night,
Just read the file and clean it right.
Hop hop, the temp file disappears!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the crash fix in tar.xz extraction timeout handling.
Description check ✅ Passed The description directly explains the reader-thread crash and the redirect-to-file fix.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ADFA-3945-tarxz-reader-thread-ioexception

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Daniel-ADFA Daniel-ADFA self-requested a review June 23, 2026 20:25

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt`:
- Around line 293-295: The process lifecycle in IdeArchiveServiceImpl’s tar
execution block is incomplete: destroyForcibly() is asynchronous, and
InterruptedException from waitFor(...) can leave the spawned process running.
Update the tar handling around process.waitFor(...) so that when completion
times out or an interruption occurs, the process is forcibly terminated and then
waited on before continuing, and make sure the interruption path in this method
cleans up the process and preserves the interrupted state.
- Around line 312-313: The temporary extraction log cleanup in
IdeArchiveServiceImpl is ignoring deletion failures, so the log can remain
behind without notice. Update the cleanup path around the .also block after
extraction to check the result of logFile.delete(), and if it fails, record a
warning or error with the existing logger so the failure is visible. Use the
logFile cleanup point in this extraction flow to keep the behavior clear and
localized.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 93a1874f-bc7d-4869-b931-45ba99a7294c

📥 Commits

Reviewing files that changed from the base of the PR and between 1d4c800 and b23bedb.

📒 Files selected for processing (1)
  • plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt

Comment on lines 293 to 295
val completed = process.waitFor(2, TimeUnit.MINUTES)
if (!completed) process.destroyForcibly()
reader.join()
exitCode = if (completed) process.exitValue() else -1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Wait for the forcibly killed tar process and clean it up on interruption.

destroyForcibly() is asynchronous, so the method can return/read/delete the log while tar is still exiting. Also, if waitFor(...) throws InterruptedException, the started process is never destroyed.

Suggested lifecycle handling
-                val completed = process.waitFor(2, TimeUnit.MINUTES)
-                if (!completed) process.destroyForcibly()
-                exitCode = if (completed) process.exitValue() else -1
+                try {
+                    val completed = process.waitFor(2, TimeUnit.MINUTES)
+                    if (completed) {
+                        exitCode = process.exitValue()
+                    } else {
+                        process.destroyForcibly()
+                        process.waitFor(10, TimeUnit.SECONDS)
+                        exitCode = -1
+                    }
+                } catch (e: InterruptedException) {
+                    process.destroyForcibly()
+                    Thread.currentThread().interrupt()
+                    throw e
+                }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val completed = process.waitFor(2, TimeUnit.MINUTES)
if (!completed) process.destroyForcibly()
reader.join()
exitCode = if (completed) process.exitValue() else -1
try {
val completed = process.waitFor(2, TimeUnit.MINUTES)
if (completed) {
exitCode = process.exitValue()
} else {
process.destroyForcibly()
process.waitFor(10, TimeUnit.SECONDS)
exitCode = -1
}
} catch (e: InterruptedException) {
process.destroyForcibly()
Thread.currentThread().interrupt()
throw e
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt`
around lines 293 - 295, The process lifecycle in IdeArchiveServiceImpl’s tar
execution block is incomplete: destroyForcibly() is asynchronous, and
InterruptedException from waitFor(...) can leave the spawned process running.
Update the tar handling around process.waitFor(...) so that when completion
times out or an interruption occurs, the process is forcibly terminated and then
waited on before continuing, and make sure the interruption path in this method
cleans up the process and preserves the interrupted state.

Comment on lines +312 to +313
}.also {
logFile.delete()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Handle failed deletion of the temporary extraction log.

The log contains combined process output and currently remains silently if deletion fails.

Suggested cleanup hardening
-        }.also {
-            logFile.delete()
+        }.also {
+            if (logFile.exists() && !logFile.delete()) {
+                logger.warn("Failed to delete Termux extraction log: ${logFile.absolutePath}")
+                logFile.deleteOnExit()
+            }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
}.also {
logFile.delete()
}.also {
if (logFile.exists() && !logFile.delete()) {
logger.warn("Failed to delete Termux extraction log: ${logFile.absolutePath}")
logFile.deleteOnExit()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeArchiveServiceImpl.kt`
around lines 312 - 313, The temporary extraction log cleanup in
IdeArchiveServiceImpl is ignoring deletion failures, so the log can remain
behind without notice. Update the cleanup path around the .also block after
extraction to check the result of logFile.delete(), and if it fails, record a
warning or error with the existing logger so the failure is visible. Use the
logFile cleanup point in this extraction flow to keep the behavior clear and
localized.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants