From 79712328cf6a8fdaa384fc825ba3a2b43030ee25 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 09:01:26 +0000 Subject: [PATCH 1/2] cleanup(android): rethrow CancellationException in UriContentReader A cancelled picker read was caught as Exception and returned as null or Unreadable. Rethrow CancellationException so cancellation propagates. Co-authored-by: Devil <9thLevelSoftware@users.noreply.github.com> --- .../util/UriContentReaderCancellationTest.kt | 103 ++++++++++++++++++ .../util/UriContentReader.android.kt | 5 + 2 files changed, 108 insertions(+) create mode 100644 shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/UriContentReaderCancellationTest.kt diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/UriContentReaderCancellationTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/UriContentReaderCancellationTest.kt new file mode 100644 index 000000000..833bb0983 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/UriContentReaderCancellationTest.kt @@ -0,0 +1,103 @@ +package com.devil.phoenixproject.util + +import android.content.Context +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import java.io.IOException +import java.io.InputStream +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * A cancelled content read propagates out of [readUriContent] and [readUriContentUpTo]. + * [CancellationException] extends [Exception], so the Android readers rethrow it + * and map every other failure to null / [BoundedUriContent.Unreadable]. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class UriContentReaderCancellationTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Before + fun setUp() { + startKoin { + modules(module { single { context } }) + } + } + + @After + fun tearDown() { + stopKoin() + } + + @Test + fun readUriContent_rethrowsCancellation() = runTest { + val cancellation = CancellationException("picker dismissed") + register("content://files/routine.csv", ThrowingStream(cancellation)) + + val thrown = assertFailsWith { + readUriContent("content://files/routine.csv") + } + + assertEquals(cancellation.message, thrown.message) + } + + @Test + fun readUriContentUpTo_rethrowsCancellation() = runTest { + val cancellation = CancellationException("picker dismissed") + register("content://files/routine.csv", ThrowingStream(cancellation)) + + val thrown = assertFailsWith { + readUriContentUpTo("content://files/routine.csv", maxBytes = 100) + } + + assertEquals(cancellation.message, thrown.message) + } + + @Test + fun otherReadFailuresStayUnreadable() = runTest { + register("content://files/missing.csv", ThrowingStream(IOException("denied"))) + register("content://files/bounded.csv", ThrowingStream(IOException("denied"))) + + assertNull(readUriContent("content://files/missing.csv")) + assertEquals( + BoundedUriContent.Unreadable, + readUriContentUpTo("content://files/bounded.csv", maxBytes = 100), + ) + } + + @Test + fun contentWithinTheLimitIsRead() = runTest { + register("content://files/routine.csv", "a,b\n".byteInputStream()) + register("content://files/bounded.csv", "a,b\n".byteInputStream()) + + assertEquals("a,b\n", readUriContent("content://files/routine.csv")) + assertEquals( + BoundedUriContent.Read("a,b\n"), + readUriContentUpTo("content://files/bounded.csv", maxBytes = 100), + ) + } + + private fun register(uri: String, stream: InputStream) { + shadowOf(context.contentResolver).registerInputStream(Uri.parse(uri), stream) + } + + private class ThrowingStream(private val error: Exception) : InputStream() { + override fun read(): Int = throw error + + override fun read(b: ByteArray, off: Int, len: Int): Int = throw error + } +} diff --git a/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/UriContentReader.android.kt b/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/UriContentReader.android.kt index a569e7480..43e858cdc 100644 --- a/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/UriContentReader.android.kt +++ b/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/UriContentReader.android.kt @@ -3,6 +3,7 @@ package com.devil.phoenixproject.util import android.content.Context import androidx.core.net.toUri import co.touchlab.kermit.Logger +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.koin.java.KoinJavaComponent.getKoin @@ -20,6 +21,8 @@ actual suspend fun readUriContent(uriOrPath: String): String? = withContext(Disp openUriOrPath(uriOrPath)?.use { stream -> stream.bufferedReader().readText() } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.e(e) { "Failed to read URI content: $uriOrPath" } null @@ -29,6 +32,8 @@ actual suspend fun readUriContent(uriOrPath: String): String? = withContext(Disp actual suspend fun readUriContentUpTo(uriOrPath: String, maxBytes: Int): BoundedUriContent = withContext(Dispatchers.IO) { try { openUriOrPath(uriOrPath)?.use { stream -> readUpTo(stream, maxBytes) } ?: BoundedUriContent.Unreadable + } catch (e: CancellationException) { + throw e } catch (e: Exception) { log.e(e) { "Failed to read URI content: $uriOrPath" } BoundedUriContent.Unreadable From 47c54dcbcb5488060fcdef131a4a9cd24f5fc5a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:26:47 +0000 Subject: [PATCH 2/2] cleanup(android): interrupt the blocking URI read on cancellation The provider read inside withContext(IO) is blocking, so cancelling the caller never threw inside the try: withContext raised the cancellation after the read finished, and the new rethrow could not fire. Wrap both reads in runInterruptible, as AndroidCsvImportIntake.readBounded already does, so cancellation interrupts the reading thread and surfaces inside the try where it is rethrown. Add tests that cancel the caller while the provider read is in flight and assert the read is interrupted and the caller ends cancelled. Import kotlinx.coroutines.CancellationException like the other Android readers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015tsDJ6LzV6FwGD3VQwfNEn --- .../util/UriContentReaderCancellationTest.kt | 64 ++++++++++++++++++- .../util/UriContentReader.android.kt | 9 ++- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/UriContentReaderCancellationTest.kt b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/UriContentReaderCancellationTest.kt index 833bb0983..3fb36181a 100644 --- a/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/UriContentReaderCancellationTest.kt +++ b/shared/src/androidHostTest/kotlin/com/devil/phoenixproject/util/UriContentReaderCancellationTest.kt @@ -5,10 +5,16 @@ import android.net.Uri import androidx.test.core.app.ApplicationProvider import java.io.IOException import java.io.InputStream +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import kotlin.coroutines.cancellation.CancellationException import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before @@ -24,7 +30,9 @@ import org.robolectric.annotation.Config /** * A cancelled content read propagates out of [readUriContent] and [readUriContentUpTo]. * [CancellationException] extends [Exception], so the Android readers rethrow it - * and map every other failure to null / [BoundedUriContent.Unreadable]. + * and map every other failure to null / [BoundedUriContent.Unreadable]. Cancelling the + * caller mid-read interrupts the reading thread, so an interruptible provider read stops + * instead of running to completion. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [28]) @@ -67,6 +75,32 @@ class UriContentReaderCancellationTest { assertEquals(cancellation.message, thrown.message) } + @Test + fun cancellingReadUriContentInterruptsTheInFlightRead() = runBlocking { + val stream = BlockingStream() + register("content://files/slow.csv", stream) + + val read = async(Dispatchers.Default) { readUriContent("content://files/slow.csv") } + assertTrue(stream.started.await(LATCH_SECONDS, TimeUnit.SECONDS), "provider read never started") + read.cancel() + + assertTrue(stream.interrupted.await(LATCH_SECONDS, TimeUnit.SECONDS), "blocking read was not interrupted") + assertFailsWith { read.await() } + } + + @Test + fun cancellingReadUriContentUpToInterruptsTheInFlightRead() = runBlocking { + val stream = BlockingStream() + register("content://files/slow.csv", stream) + + val read = async(Dispatchers.Default) { readUriContentUpTo("content://files/slow.csv", maxBytes = 100) } + assertTrue(stream.started.await(LATCH_SECONDS, TimeUnit.SECONDS), "provider read never started") + read.cancel() + + assertTrue(stream.interrupted.await(LATCH_SECONDS, TimeUnit.SECONDS), "blocking read was not interrupted") + assertFailsWith { read.await() } + } + @Test fun otherReadFailuresStayUnreadable() = runTest { register("content://files/missing.csv", ThrowingStream(IOException("denied"))) @@ -100,4 +134,32 @@ class UriContentReaderCancellationTest { override fun read(b: ByteArray, off: Int, len: Int): Int = throw error } + + /** Blocks in [read] until the reading thread is interrupted, then rethrows the interrupt. */ + private class BlockingStream : InputStream() { + val started = CountDownLatch(1) + val interrupted = CountDownLatch(1) + + override fun read(): Int = block() + + override fun read(b: ByteArray, off: Int, len: Int): Int = block() + + private fun block(): Int { + started.countDown() + try { + Thread.sleep(BLOCK_MILLIS) + } catch (e: InterruptedException) { + interrupted.countDown() + throw e + } + return -1 + } + } + + private companion object { + const val LATCH_SECONDS = 5L + + /** Longer than [LATCH_SECONDS], so an uninterrupted read fails the test instead of passing. */ + const val BLOCK_MILLIS = 15_000L + } } diff --git a/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/UriContentReader.android.kt b/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/UriContentReader.android.kt index 43e858cdc..f4427987d 100644 --- a/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/UriContentReader.android.kt +++ b/shared/src/androidMain/kotlin/com/devil/phoenixproject/util/UriContentReader.android.kt @@ -3,8 +3,9 @@ package com.devil.phoenixproject.util import android.content.Context import androidx.core.net.toUri import co.touchlab.kermit.Logger -import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext import org.koin.java.KoinJavaComponent.getKoin import java.io.ByteArrayOutputStream @@ -15,11 +16,13 @@ private val log = Logger.withTag("UriContentReader") /** * Android implementation: reads content from a `content://` URI or a plain file path. * Uses the Application [Context] retrieved from Koin (registered by the Android platform module). + * Cancelling the caller interrupts the reading thread, so a stream that honours interrupts + * stops early, and the cancellation is rethrown rather than mapped to null / Unreadable. */ actual suspend fun readUriContent(uriOrPath: String): String? = withContext(Dispatchers.IO) { try { openUriOrPath(uriOrPath)?.use { stream -> - stream.bufferedReader().readText() + runInterruptible { stream.bufferedReader().readText() } } } catch (e: CancellationException) { throw e @@ -31,7 +34,7 @@ actual suspend fun readUriContent(uriOrPath: String): String? = withContext(Disp actual suspend fun readUriContentUpTo(uriOrPath: String, maxBytes: Int): BoundedUriContent = withContext(Dispatchers.IO) { try { - openUriOrPath(uriOrPath)?.use { stream -> readUpTo(stream, maxBytes) } ?: BoundedUriContent.Unreadable + openUriOrPath(uriOrPath)?.use { stream -> runInterruptible { readUpTo(stream, maxBytes) } } ?: BoundedUriContent.Unreadable } catch (e: CancellationException) { throw e } catch (e: Exception) {