From 8fdacc25531113a9d2f53c0808d1c5d8c852c3a5 Mon Sep 17 00:00:00 2001 From: Sunny Chung Date: Wed, 13 Nov 2024 09:27:03 +0800 Subject: [PATCH] update CodeEditorView to run on multiple threads to avoid frequent lagging while searching and typing in a large text, and add thread-safe ConcurrentBigText --- .../hellohttp/ux/CodeEditorView.kt | 89 ++++++----- .../hellohttp/ux/bigtext/BigText.kt | 3 + .../hellohttp/ux/bigtext/ConcurrentBigText.kt | 125 +++++++++++++++ .../ux/bigtext/ConcurrentBigTextFieldState.kt | 27 ++++ .../MultipleIncrementalTransformation.kt | 13 ++ .../incremental/MultipleTextDecorator.kt | 13 ++ .../test/bigtext/BigTextVerifyImpl.kt | 97 +++++++++++- .../ConcurrentBigTextThreadSafetyTest.kt | 148 ++++++++++++++++++ 8 files changed, 474 insertions(+), 41 deletions(-) create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/ConcurrentBigText.kt create mode 100644 src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/ConcurrentBigTextFieldState.kt create mode 100644 src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/ConcurrentBigTextThreadSafetyTest.kt diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt index ceb801e2..2dffa343 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/CodeEditorView.kt @@ -75,7 +75,7 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTran import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformerImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextViewState import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.abbr -import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.rememberLargeAnnotatedBigTextFieldState +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.rememberConcurrentLargeAnnotatedBigTextFieldState import com.sunnychung.application.multiplatform.hellohttp.ux.compose.rememberLast import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalColor import com.sunnychung.application.multiplatform.hellohttp.ux.local.LocalFont @@ -90,6 +90,7 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incr import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.MultipleTextDecorator import com.sunnychung.application.multiplatform.hellohttp.ux.transformation.incremental.SearchHighlightDecorator import com.sunnychung.lib.multiplatform.kdatetime.extension.milliseconds +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest @@ -98,6 +99,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.regex.Pattern import kotlin.random.Random @@ -133,7 +135,7 @@ fun CodeEditorView( var layoutResult by remember { mutableStateOf(null) } - val bigTextFieldState: BigTextFieldState by rememberLargeAnnotatedBigTextFieldState(initialValue = initialText, cacheKey) + val bigTextFieldState: BigTextFieldState by rememberConcurrentLargeAnnotatedBigTextFieldState(initialValue = initialText, cacheKey) val bigTextValue: BigText = bigTextFieldState.text var bigTextValueId by remember(bigTextFieldState) { mutableStateOf(Random.nextLong()) } @@ -262,30 +264,37 @@ fun CodeEditorView( log.d { "get search pattern ${searchPattern?.pattern}" } LaunchedEffect(bigTextValue) { - searchTrigger.receiveAsFlow() - .debounce(210L) - .filter { isSearchVisible } - .collectLatest { - log.d { "search triggered ${searchPatternLatest?.pattern}" } - if (searchPatternLatest != null) { - try { - val fullText = bigTextValue.buildString() - val r = searchPatternLatest!! - .findAll(fullText) - .map { it.range } - .sortedBy { it.start } - .toList() - searchResultRangesState.value = r - searchResultRangeTreeState.value = TreeRangeMaps.from(r) - log.d { "search r ${r.size}" } - } catch (e: Throwable) { - log.d(e) { "search error" } + withContext(Dispatchers.IO) { + searchTrigger.receiveAsFlow() + .debounce(210L) + .filter { isSearchVisible } + .collectLatest { + log.d { "search triggered ${searchPatternLatest?.pattern}" } + if (searchPatternLatest != null) { + try { + val fullText = bigTextValue.buildString() + val r = searchPatternLatest!! + .findAll(fullText) + .map { it.range } + .sortedBy { it.start } + .toList() + val treeRangeMap = TreeRangeMaps.from(r) + withContext(Dispatchers.Main) { + searchResultRangesState.value = r + searchResultRangeTreeState.value = treeRangeMap + log.d { "search r ${r.size}" } + } + } catch (e: Throwable) { + log.d(e) { "search error" } + } + } else { + withContext(Dispatchers.Main) { + searchResultRangesState.value = null + searchResultRangeTreeState.value = null + } } - } else { - searchResultRangesState.value = null - searchResultRangeTreeState.value = null } - } + } } var searchResultSummary = if (!searchResultRanges.isNullOrEmpty()) { "${searchResultViewIndex + 1}/${searchResultRanges?.size}" @@ -488,21 +497,25 @@ fun CodeEditorView( } else { LaunchedEffect(bigTextFieldState, onTextChange) { // FIXME the flow is frequently recreated log.i { "CEV recreate change collection flow $bigTextFieldState ${onTextChange.hashCode()}" } - bigTextFieldState.valueChangesFlow - .onEach { log.d { "bigTextFieldState change each ${it.changeId}" } } - .chunkedLatest(200.milliseconds()) - .collect { - log.d { "bigTextFieldState change collect ${it.changeId} ${it.bigText.length} ${it.bigText.buildString()}" } - onTextChange?.let { onTextChange -> - val string = it.bigText.buildCharSequence() as AnnotatedString - log.d { "${bigTextFieldState.text} : ${it.bigText} onTextChange(${string.text.abbr()})" } - onTextChange(string.text) - } - bigTextValueId = it.changeId - searchTrigger.trySend(Unit) + withContext(Dispatchers.IO) { + bigTextFieldState.valueChangesFlow + .onEach { log.d { "bigTextFieldState change each ${it.changeId}" } } + .chunkedLatest(200.milliseconds()) + .collect { + log.d { "bigTextFieldState change collect ${it.changeId} ${it.bigText.length} ${it.bigText.buildString()}" } + onTextChange?.let { onTextChange -> + val string = it.bigText.buildCharSequence() as AnnotatedString + withContext(Dispatchers.Main) { + log.d { "${bigTextFieldState.text} : ${it.bigText} onTextChange(${string.text.abbr()})" } + onTextChange(string.text) + } + } + bigTextValueId = it.changeId + searchTrigger.trySend(Unit) - bigTextFieldState.markConsumed(it.sequence) - } + bigTextFieldState.markConsumed(it.sequence) + } + } } var mouseHoverVariable by remember(bigTextFieldState) { mutableStateOf(null) } diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt index 1ba74c13..03a2a081 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/BigText.kt @@ -36,6 +36,9 @@ interface BigText { var undoMetadataSupplier: (() -> Any?)? var changeHook: BigTextChangeHook? + val isThreadSafe: Boolean + get() = false + fun buildString(): String fun buildCharSequence(): CharSequence diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/ConcurrentBigText.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/ConcurrentBigText.kt new file mode 100644 index 00000000..8fa16ddf --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/ConcurrentBigText.kt @@ -0,0 +1,125 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write + +class ConcurrentBigText(private val delegate: BigText) : BigText { + + private val lock = ReentrantReadWriteLock() + + override val length: Int + get() = lock.read { delegate.length } + override val lastIndex: Int + get() = lock.read { delegate.lastIndex } + override val isEmpty: Boolean + get() = lock.read { delegate.isEmpty } + override val isNotEmpty: Boolean + get() = lock.read { delegate.isNotEmpty } + override val hasLayouted: Boolean + get() = lock.read { delegate.hasLayouted } + override val layouter: TextLayouter? + get() = lock.read { delegate.layouter } + override val numOfLines: Int + get() = lock.read { delegate.numOfLines } + override val numOfRows: Int + get() = lock.read { delegate.numOfRows } + override val lastRowIndex: Int + get() = lock.read { delegate.lastRowIndex } + override val numOfOriginalLines: Int + get() = lock.read { delegate.numOfOriginalLines } + override val chunkSize: Int + get() = lock.read { delegate.chunkSize } + override val undoHistoryCapacity: Int + get() = lock.read { delegate.undoHistoryCapacity } + override val textBufferFactory: (capacity: Int) -> TextBuffer + get() = lock.read { delegate.textBufferFactory } + override val charSequenceBuilderFactory: (capacity: Int) -> Appendable + get() = lock.read { delegate.charSequenceBuilderFactory } + override val charSequenceFactory: (Appendable) -> CharSequence + get() = lock.read { delegate.charSequenceFactory } + override val tree: LengthTree + get() = lock.read { delegate.tree } + override val contentWidth: Float? + get() = lock.read { delegate.contentWidth } + override var undoMetadataSupplier: (() -> Any?)? + get() = lock.read { delegate.undoMetadataSupplier } + set(value) { lock.write { delegate.undoMetadataSupplier = value } } + override var changeHook: BigTextChangeHook? + get() = lock.read { delegate.changeHook } + set(value) { lock.write { delegate.changeHook = value } } + + override val isThreadSafe: Boolean + get() = true + + override fun buildString(): String = lock.read { delegate.buildString() } + + override fun buildCharSequence(): CharSequence = lock.read { delegate.buildCharSequence() } + + override fun substring(start: Int, endExclusive: Int): CharSequence = lock.read { delegate.substring(start, endExclusive) } + + override fun findLineString(lineIndex: Int): CharSequence = lock.read { delegate.findLineString(lineIndex) } + + override fun findRowString(rowIndex: Int): CharSequence = lock.read { delegate.findRowString(rowIndex) } + + override fun append(text: CharSequence): Int = lock.write { delegate.append(text) } + + override fun insertAt(pos: Int, text: CharSequence): Int = lock.write { delegate.insertAt(pos, text) } + + override fun delete(start: Int, endExclusive: Int): Int = lock.write { delegate.delete(start, endExclusive) } + + override fun replace(start: Int, endExclusive: Int, text: CharSequence) = lock.write { + delegate.replace(start, endExclusive, text) + } + + override fun replace(range: IntRange, text: CharSequence) = lock.write { + delegate.replace(range, text) + } + + override fun recordCurrentChangeSequenceIntoUndoHistory() = lock.write { delegate.recordCurrentChangeSequenceIntoUndoHistory() } + + override fun undo(callback: BigTextChangeCallback?): Pair = lock.write { delegate.undo(callback) } + + override fun redo(callback: BigTextChangeCallback?): Pair = lock.write { delegate.redo(callback) } + + override fun isUndoable(): Boolean = lock.read { delegate.isUndoable() } + + override fun isRedoable(): Boolean = lock.read { delegate.isRedoable() } + + override fun findLineAndColumnFromRenderPosition(renderPosition: Int): Pair = lock.read { delegate.findLineAndColumnFromRenderPosition(renderPosition) } + + override fun findRenderCharIndexByLineAndColumn(lineIndex: Int, columnIndex: Int): Int = lock.read { delegate.findRenderCharIndexByLineAndColumn(lineIndex, columnIndex) } + + override fun findPositionStartOfLine(lineIndex: Int): Int = lock.read { delegate.findPositionStartOfLine(lineIndex) } + + override fun findLineIndexByRowIndex(rowIndex: Int): Int = lock.read { delegate.findLineIndexByRowIndex(rowIndex) } + + override fun findFirstRowIndexOfLine(lineIndex: Int): Int = lock.read { delegate.findFirstRowIndexOfLine(lineIndex) } + + override fun setLayouter(layouter: TextLayouter) = lock.write { delegate.setLayouter(layouter) } + + override fun setContentWidth(contentWidth: Float) = lock.write { delegate.setContentWidth(contentWidth) } + + override fun layout() = lock.write { delegate.layout() } + + // the first call to `hashCode()` would write to cache +// override fun hashCode(): Int = lock.write { delegate.hashCode() } + // currently, BigTextImpl has no custom implementation over built-in's one, so no lock is needed. + override fun hashCode(): Int = delegate.hashCode() + +// override fun equals(other: Any?): Boolean = lock.read { delegate.equals(other) } + // currently, BigTextImpl has no custom implementation over built-in's one, so no lock is needed. + override fun equals(other: Any?): Boolean { + if (other !is ConcurrentBigText) return delegate.equals(other) + return delegate.equals(other.delegate) + } + + override fun inspect(label: String): String = lock.read { delegate.inspect(label) } + + override fun printDebug(label: String) = lock.read { delegate.printDebug(label) } + + fun withWriteLock(operation: (BigText) -> Unit) = lock.write { operation(delegate) } + + fun withReadLock(operation: (BigText) -> Unit) = lock.read { operation(delegate) } + +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/ConcurrentBigTextFieldState.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/ConcurrentBigTextFieldState.kt new file mode 100644 index 00000000..2caf37ae --- /dev/null +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/bigtext/ConcurrentBigTextFieldState.kt @@ -0,0 +1,27 @@ +package com.sunnychung.application.multiplatform.hellohttp.ux.bigtext + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.text.AnnotatedString + +/** + * Create a BigTextFieldState with a thread-safe large text buffer. Specifically, only the `BigText` class is thread-safe. + * + * `BigTextViewState` is NOT thread-safe and can only be manipulated in UI thread. + * + * The argument `initialValue` is only used when there is a cache miss using the cache key `cacheKey`. + */ +@Composable +fun rememberConcurrentLargeAnnotatedBigTextFieldState(initialValue: String = "", vararg cacheKeys: Any?): MutableState { + return rememberSaveable(*cacheKeys) { + log.i { "cache miss concurrent 1" } + mutableStateOf( + BigTextFieldState( + ConcurrentBigText(BigText.createFromLargeAnnotatedString(AnnotatedString(initialValue))), + BigTextViewState() + ) + ) + } +} diff --git a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleIncrementalTransformation.kt b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleIncrementalTransformation.kt index 64509a76..aaa475ec 100644 --- a/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleIncrementalTransformation.kt +++ b/src/jvmMain/kotlin/com/sunnychung/application/multiplatform/hellohttp/ux/transformation/incremental/MultipleIncrementalTransformation.kt @@ -36,4 +36,17 @@ class MultipleIncrementalTransformation(val transformations: List): BigTextDeco } return text } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MultipleTextDecorator) return false + + if (decorators != other.decorators) return false + + return true + } + + override fun hashCode(): Int { + return decorators.hashCode() + } } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt index 44521a14..4eb5ab2a 100644 --- a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/BigTextVerifyImpl.kt @@ -2,6 +2,8 @@ package com.sunnychung.application.multiplatform.hellohttp.test.bigtext import com.sunnychung.application.multiplatform.hellohttp.extension.length import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeCallback +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextChangeHook import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextNodeValue import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTransformOffsetMapping @@ -9,6 +11,7 @@ import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextTran import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.InefficientBigText import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.LengthTree import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.TextBuffer +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.TextLayouter import java.util.TreeMap import kotlin.test.assertEquals @@ -25,8 +28,16 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { if (chunkSize > 0) BigTextImpl(chunkSize) else BigTextImpl() ) - val tree: LengthTree + override val tree: LengthTree get() = bigTextImpl.tree + override val contentWidth: Float? + get() = TODO("Not yet implemented") + override var undoMetadataSupplier: (() -> Any?)? + get() = TODO("Not yet implemented") + set(value) {} + override var changeHook: BigTextChangeHook? + get() = TODO("Not yet implemented") + set(value) {} val buffers: MutableList get() = bigTextImpl.buffers @@ -42,6 +53,34 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { assert(l == tl) { "length expected $tl, actual $l" } return l } + override val lastIndex: Int + get() = TODO("Not yet implemented") + override val isEmpty: Boolean + get() = TODO("Not yet implemented") + override val isNotEmpty: Boolean + get() = TODO("Not yet implemented") + override val hasLayouted: Boolean + get() = TODO("Not yet implemented") + override val layouter: TextLayouter? + get() = TODO("Not yet implemented") + override val numOfLines: Int + get() = TODO("Not yet implemented") + override val numOfRows: Int + get() = TODO("Not yet implemented") + override val lastRowIndex: Int + get() = TODO("Not yet implemented") + override val numOfOriginalLines: Int + get() = TODO("Not yet implemented") + override val chunkSize: Int + get() = TODO("Not yet implemented") + override val undoHistoryCapacity: Int + get() = TODO("Not yet implemented") + override val textBufferFactory: (capacity: Int) -> TextBuffer + get() = TODO("Not yet implemented") + override val charSequenceBuilderFactory: (capacity: Int) -> Appendable + get() = TODO("Not yet implemented") + override val charSequenceFactory: (Appendable) -> CharSequence + get() = TODO("Not yet implemented") val originalLength: Int get() = length - transformOffsetsByPosition.values.sum() @@ -66,6 +105,14 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { return r } + override fun findLineString(lineIndex: Int): CharSequence { + TODO("Not yet implemented") + } + + override fun findRowString(rowIndex: Int): CharSequence { + TODO("Not yet implemented") + } + override fun append(text: CharSequence): Int { println("append ${text.length}") val r = bigTextImpl.append(text) @@ -121,6 +168,26 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { replace(range, text, BigTextTransformOffsetMapping.Incremental) } + override fun recordCurrentChangeSequenceIntoUndoHistory() { + TODO("Not yet implemented") + } + + override fun undo(callback: BigTextChangeCallback?): Pair { + TODO("Not yet implemented") + } + + override fun redo(callback: BigTextChangeCallback?): Pair { + TODO("Not yet implemented") + } + + override fun isUndoable(): Boolean { + TODO("Not yet implemented") + } + + override fun isRedoable(): Boolean { + TODO("Not yet implemented") + } + fun replace(range: IntRange, text: CharSequence, offsetMapping: BigTextTransformOffsetMapping) { println("replace $range -> ${text.length}") var r: Int = 0 @@ -265,6 +332,30 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { TODO("Not yet implemented") } + override fun findPositionStartOfLine(lineIndex: Int): Int { + TODO("Not yet implemented") + } + + override fun findLineIndexByRowIndex(rowIndex: Int): Int { + TODO("Not yet implemented") + } + + override fun findFirstRowIndexOfLine(lineIndex: Int): Int { + TODO("Not yet implemented") + } + + override fun setLayouter(layouter: TextLayouter) { + TODO("Not yet implemented") + } + + override fun setContentWidth(contentWidth: Float) { + TODO("Not yet implemented") + } + + override fun layout() { + TODO("Not yet implemented") + } + override fun hashCode(): Int { val r = bigTextImpl.hashCode() val tr = stringImpl.hashCode() @@ -295,7 +386,7 @@ internal class BigTextVerifyImpl(bigTextImpl: BigTextImpl) : BigText { } } - fun printDebug(label: String = "") = bigTextImpl.printDebug(label) + override fun printDebug(label: String) = bigTextImpl.printDebug(label) - fun inspect(label: String = "") = bigTextImpl.inspect(label) + override fun inspect(label: String) = bigTextImpl.inspect(label) } diff --git a/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/ConcurrentBigTextThreadSafetyTest.kt b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/ConcurrentBigTextThreadSafetyTest.kt new file mode 100644 index 00000000..faf30b4d --- /dev/null +++ b/src/jvmTest/kotlin/com/sunnychung/application/multiplatform/hellohttp/test/bigtext/ConcurrentBigTextThreadSafetyTest.kt @@ -0,0 +1,148 @@ +package com.sunnychung.application.multiplatform.hellohttp.test.bigtext + +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigText +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.BigTextImpl +import com.sunnychung.application.multiplatform.hellohttp.ux.bigtext.ConcurrentBigText +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals + +class ConcurrentBigTextThreadSafetyTest { + + @Test + fun concurrentAppend() { + val NUM_ITERATIONS_PER_THREAD = 1000000 + val NUM_THREADS = 10 + + fun CoroutineScope.testOp(text: BigText) { + (0 until NUM_THREADS).forEach { + launch { + (0 until NUM_ITERATIONS_PER_THREAD).forEach { + text.append("_") + } + } + } + } + + // first, check BigText is thread-unsafe + BigTextImpl().let { text -> + try { + runBlocking(Dispatchers.IO) { + testOp(text) + } + assert(text.length < NUM_THREADS * NUM_ITERATIONS_PER_THREAD) + } catch (_: Exception) { + println("Caught exception while running thread-unsafe test. It is safe to ignore.") + } + } + + // next, check ConcurrentBigText is thread-safe + ConcurrentBigText(BigTextImpl()).let { text -> + runBlocking(Dispatchers.IO) { + testOp(text) + } + assertEquals(NUM_THREADS * NUM_ITERATIONS_PER_THREAD, text.length) + } + } + + @Test + fun concurrentInsertAndLength() { + val NUM_ITERATIONS_PER_THREAD = 100000 + val NUM_THREADS = 10 + val random = Random(12345) + + fun CoroutineScope.testOp(text: BigText) { + (0 until NUM_THREADS).forEach { + launch { + (0 until NUM_ITERATIONS_PER_THREAD).forEach { + text.insertAt(random.nextInt(0, text.length + 1),"_") + } + } + } + } + + // first, check BigText is thread-unsafe + BigTextImpl().let { text -> + try { + runBlocking(Dispatchers.IO) { + withContext(CoroutineExceptionHandler { context, e -> + println("Caught exception in coroutine while running thread-unsafe test. It is safe to ignore.") + }) { + testOp(text) + } + } + assert(text.length < NUM_THREADS * NUM_ITERATIONS_PER_THREAD) + } catch (_: Exception) { + println("Caught exception while running thread-unsafe test. It is safe to ignore.") + } + } + + // next, check ConcurrentBigText is thread-safe + ConcurrentBigText(BigTextImpl()).let { text -> + runBlocking(Dispatchers.IO) { + testOp(text) + } + assertEquals(NUM_THREADS * NUM_ITERATIONS_PER_THREAD, text.length) + } + } + + @Test + fun concurrentDeleteAndLength() { + val NUM_ITERATIONS_PER_THREAD = 100000 + val NUM_THREADS = 10 + val random = Random(12345) + val initialText = (0 until 1200000).joinToString { (it % 10).toString() } + + fun CoroutineScope.testOp(text: BigText) { + (0 until NUM_THREADS).forEach { + launch { + (0 until NUM_ITERATIONS_PER_THREAD).forEach { + fun op() { + val start = random.nextInt(0, text.length) + text.delete(start, start + 1) + } + if (text is ConcurrentBigText) { + text.withWriteLock { + op() + } + } else { + op() + } + } + } + } + } + + // first, check BigText is thread-unsafe + BigTextImpl().let { text -> + try { + runBlocking(Dispatchers.IO) { + text.append(initialText) + withContext(CoroutineExceptionHandler { context, e -> + println("Caught exception in coroutine while running thread-unsafe test. It is safe to ignore.") + }) { + testOp(text) + } + } + assert(text.length > initialText.length - NUM_THREADS * NUM_ITERATIONS_PER_THREAD) + } catch (_: Exception) { + println("Caught exception while running thread-unsafe test. It is safe to ignore.") + } + } + + // next, check ConcurrentBigText is thread-safe + ConcurrentBigText(BigTextImpl()).let { text -> + runBlocking(Dispatchers.IO) { + text.append(initialText) + testOp(text) + } + assertEquals(initialText.length - NUM_THREADS * NUM_ITERATIONS_PER_THREAD, text.length) + } + } +}