fix(editor): Dispatchers.IO thread pool is exhausted if files are continuously opened and closed (fixes #1551)

This commit is contained in:
Akash Yadav
2023-12-18 00:11:26 +05:30
parent 29993c5ab4
commit cd55f9d662
3 changed files with 101 additions and 47 deletions
@@ -83,16 +83,17 @@ import io.github.rosemoe.sora.widget.CodeEditor
import io.github.rosemoe.sora.widget.component.Magnifier
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.newSingleThreadContext
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.io.Closeable
import java.io.File
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
/**
* A view that handles opened code editor.
@@ -112,6 +113,15 @@ class CodeEditorView(
private val codeEditorScope = CoroutineScope(
Dispatchers.Default + CoroutineName("CodeEditorView"))
/**
* The [CoroutineContext][kotlin.coroutines.CoroutineContext] used to reading and writing the file
* in this editor. We use a separate, single-threaded context assuming that the file will be either
* read from or written to at a time, but not both. If in future we add support for anything like
* that, the number of thread should probably be increased.
*/
@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
private val readWriteContext = newSingleThreadContext("CodeEditorView")
private val binding: LayoutCodeEditorBinding
get() = checkNotNull(_binding) { "Binding has been destroyed" }
@@ -222,8 +232,7 @@ class CodeEditorView(
val file = this.file ?: return false
if (!isModified && file.exists()) {
log.info(file.name)
log.info("File was not modified. Skipping save operation.")
log.info("File was not modified. Skipping save operation.", file.name)
return false
}
@@ -232,16 +241,19 @@ class CodeEditorView(
return false
}
disableEditingAndRun(Dispatchers.IO) {
// Do not call suspend functions in this scope
// the writeTo function acquires lock to the Content object before writing and releases
// the lock after writing
// if there are any suspend function calls in between, the lock and unlock calls might not
// be called on the same thread
text.writeTo(file, this@CodeEditorView::updateReadWriteProgress)
}
withContext(Dispatchers.Main.immediate) {
withEditingDisabled {
withContext(readWriteContext) {
// Do not call suspend functions in this scope
// the writeTo function acquires lock to the Content object before writing and releases
// the lock after writing
// if there are any suspend function calls in between, the lock and unlock calls might not
// be called on the same thread
text.writeTo(file, this@CodeEditorView::updateReadWriteProgress)
}
}
withContext(Dispatchers.Main) {
_binding?.rwProgress?.isVisible = false
}
@@ -267,31 +279,26 @@ class CodeEditorView(
}
}
private suspend inline fun <R> disableEditingAndRun(
context: CoroutineContext = EmptyCoroutineContext,
crossinline action: CoroutineScope.() -> R
): R {
return withContext(Dispatchers.Main) {
private inline fun <R : Any?> withEditingDisabled(action: () -> R): R {
return try {
_binding?.editor?.isEditable = false
val result = withContext(context) {
action()
}
action()
} finally {
_binding?.editor?.isEditable = true
return@withContext result
}
}
private fun readFileAndApplySelection(file: File, selection: Range) {
updateReadWriteProgress(0)
codeEditorScope.launch {
val content = disableEditingAndRun(Dispatchers.IO) {
selection.validate()
file.readContent(this@CodeEditorView::updateReadWriteProgress)
}
codeEditorScope.launch(Dispatchers.Main.immediate) {
updateReadWriteProgress(0)
withEditingDisabled {
val content = withContext(readWriteContext) {
selection.validate()
file.readContent(this@CodeEditorView::updateReadWriteProgress)
}
withContext(Dispatchers.Main) {
initializeContent(content, file, selection)
_binding?.rwProgress?.isVisible = false
}
@@ -299,12 +306,13 @@ class CodeEditorView(
}
private fun initializeContent(content: Content, file: File, selection: Range) {
binding.editor.apply {
val ideEditor = binding.editor
ideEditor.postInLifecycle {
val args = Bundle().apply {
putString(IEditor.KEY_FILE, file.absolutePath)
}
setText(content, args)
ideEditor.setText(content, args)
// editor.setText(...) sets the modified flag to true
// but in this case, file is read from disk and hence the contents are not modified at all
@@ -313,11 +321,11 @@ class CodeEditorView(
markUnmodified()
postRead(file)
validateRange(selection)
setSelection(selection)
}
ideEditor.validateRange(selection)
ideEditor.setSelection(selection)
configureEditorIfNeeded()
configureEditorIfNeeded()
}
}
private fun postRead(file: File) {
@@ -514,5 +522,7 @@ class CodeEditorView(
notifyClose()
release()
}
readWriteContext.use { }
}
}
@@ -32,13 +32,14 @@ import io.github.rosemoe.sora.lang.analysis.StyleReceiver
import io.github.rosemoe.sora.lang.styling.CodeBlock
import io.github.rosemoe.sora.lang.styling.Styles
import io.github.rosemoe.sora.text.ContentReference
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.newSingleThreadContext
import java.lang.RuntimeException
import java.util.concurrent.CancellationException
import java.util.concurrent.LinkedBlockingQueue
@@ -61,11 +62,15 @@ internal class TsAnalyzeWorker(
var stylesReceiver: StyleReceiver? = null
private val analyzerScope = CoroutineScope(Dispatchers.Default + CoroutineName("TsAnalyzeWorker"))
@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
private val analyzerContext = newSingleThreadContext("TsAnalyzeWorkerContext")
private val analyzerScope = CoroutineScope(analyzerContext)
private val messageChannel = LinkedBlockingQueue<Message<*>>()
private var analyzerJob: Job? = null
private var isInitialized = false
private var isDestroyed = false
private var tree: TSTree? = null
private val localText = UTF16StringFactory.newString()
@@ -74,14 +79,28 @@ internal class TsAnalyzeWorker(
}
fun init(init: Init) {
if (isDestroyed) {
log.warn("Received Init after TsAnalyzeWorker has been destroyed. Ignoring...")
return
}
messageChannel.offer(init)
}
fun onMod(mod: Mod) {
if (isDestroyed) {
log.warn("Received Mod after TsAnalyzeWorker has been destroyed. Ignoring...")
return
}
messageChannel.offer(mod)
}
fun stop() {
log.debug("Stopping TsAnalyzeWorker...")
isDestroyed = true
analyzerContext.close()
messageChannel.clear()
analyzerJob?.cancel(CancellationException("Requested to be stopped"))
localText.close()
@@ -90,7 +109,8 @@ internal class TsAnalyzeWorker(
}
fun start() {
analyzerJob = analyzerScope.launch(Dispatchers.Default) {
check(!isDestroyed) { "TsAnalyeWorker has already been destroyed" }
analyzerJob = analyzerScope.launch {
while (isActive) {
processNextMessage()
}
@@ -105,8 +125,8 @@ internal class TsAnalyzeWorker(
}
}
private suspend fun processNextMessage() {
val message = withContext(Dispatchers.IO) { messageChannel.take() }
private fun processNextMessage() {
val message = messageChannel.take()
try {
when (message) {
@@ -50,6 +50,9 @@ object IDEColorSchemeProvider {
private const val SCHEME_IS_DARK = "scheme.isDark"
private const val SCHEME_FILE = "scheme.file"
private var isDefaultSchemeLoaded = false
private var isCurrentSchemeLoaded = false
/**
* The default color scheme.
*
@@ -58,7 +61,10 @@ object IDEColorSchemeProvider {
*/
private var defaultScheme: IDEColorScheme? = null
get() {
return field ?: getColorScheme(DEFAULT_COLOR_SCHEME).also { field = it }
return field ?: getColorScheme(DEFAULT_COLOR_SCHEME).also { scheme ->
field = scheme
isDefaultSchemeLoaded = scheme != null
}
}
/**
@@ -69,7 +75,10 @@ object IDEColorSchemeProvider {
*/
private var currentScheme: IDEColorScheme? = null
get() {
return field ?: getColorScheme(colorScheme).also { field = it }
return field ?: getColorScheme(colorScheme).also { scheme ->
field = scheme
isCurrentSchemeLoaded = scheme != null
}
}
/**
@@ -179,9 +188,21 @@ object IDEColorSchemeProvider {
context: Context,
coroutineScope: CoroutineScope,
type: String? = null,
callbackContext: CoroutineContext = Dispatchers.Main,
callbackContext: CoroutineContext = Dispatchers.Main.immediate,
callback: (SchemeAndroidIDE?) -> Unit
) {
// If the scheme has already been loaded, do not bother to dispatch an IO coroutine
// simply invoke the callback on the requested context
if (isCurrentSchemeLoaded && isDefaultSchemeLoaded) {
coroutineScope.launch(callbackContext) {
callback(readScheme(context, type))
}
return
}
// scheme has not been loaded
// load the scheme using the IO dispatcher
coroutineScope.launch(Dispatchers.IO) {
val scheme = readScheme(context, type)
withContext(callbackContext) {
@@ -252,7 +273,10 @@ object IDEColorSchemeProvider {
fun destroy() {
this.schemes.clear()
this.currentScheme = null
this.isCurrentSchemeLoaded = false
this.defaultScheme = null
this.isDefaultSchemeLoaded = false
}
@WorkerThread