Skip to content

Remove unnecessary Dispatcher.IO usage#4125

Closed
romansl wants to merge 1 commit into
meshtastic:mainfrom
romansl:dispatchers-io-fixes
Closed

Remove unnecessary Dispatcher.IO usage#4125
romansl wants to merge 1 commit into
meshtastic:mainfrom
romansl:dispatchers-io-fixes

Conversation

@romansl

@romansl romansl commented Jan 3, 2026

Copy link
Copy Markdown
Contributor

This PR implements best practices for working with coroutines in the project. The main idea is to use withContext only to make a suspend function non-blocking. All calls that do not adhere to this principle have been removed.

https://developer.android.com/kotlin/coroutines/coroutines-best-practices#main-safe
https://elizarov.medium.com/blocking-threads-suspending-coroutines-d33e11bf4761

Copilot AI 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.

Pull request overview

This PR removes unnecessary Dispatchers.IO usage from repositories, data sources, and ViewModels where operations are already asynchronous (Room DAOs, Ktor HTTP calls). However, the PR also makes several problematic changes by removing or changing dispatchers for operations that genuinely require specific thread pools.

Key Changes:

  • Removed withContext(Dispatchers.IO) wrapping from Room DAO suspend function calls across repositories and data sources
  • Removed flowOn(Dispatchers.IO) from flows backed by Room DAO queries
  • Changed CoroutineScope dispatchers from Dispatchers.IO to Dispatchers.Default in several Android services
  • Added justification comments for legitimate blocking I/O operations (file operations, ContentResolver)

Critical Issues Found:

  • Several Android services (RadioInterfaceService, MeshService, PacketHandler) had their scopes changed from Dispatchers.IO to Dispatchers.Default, which is inappropriate for services that perform I/O operations
  • ContactsViewModel now calls suspend functions synchronously within PagingData transformations, which will block the main thread
  • DatabaseManager's Room database building operation no longer runs on Dispatchers.IO, risking main thread blocking during database initialization

Reviewed changes

Copilot reviewed 30 out of 30 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
feature/settings/.../DebugViewModel.kt Added CoroutineDispatchers injection, changed Dispatchers.IO to default, removed unnecessary IO wrapping
feature/settings/.../Debug.kt Modifier chaining formatting improvements, added TODO comment for moving export logic to ViewModel
feature/settings/build.gradle.kts Added core.di dependency for CoroutineDispatchers
feature/node/.../MetricsViewModel.kt Removed dispatchers.io from viewModelScope.launch for DAO calls
feature/node/.../NodeActions.kt Scoped Dispatchers.IO narrowly to AIDL service call only
feature/node/.../NodeDetailViewModel.kt Similar scoped IO change for AIDL, removed from DAO calls
feature/node/.../PhoneLocationProvider.kt Removed flowOn and CoroutineDispatchers dependency
feature/messaging/.../QuickChatViewModel.kt Removed Dispatchers.IO from DAO operations, formatting improvements
feature/messaging/.../MessageViewModel.kt Removed Dispatchers.IO from launches, parameter formatting improvements
feature/map/.../BaseMapViewModel.kt Removed Dispatchers.IO from deleteWaypoint
feature/map/.../MapViewModel.kt Added justification comments for legitimate file I/O operations
feature/firmware/.../FirmwareFileHandler.kt Added justification comments for blocking file/network I/O
core/network/.../FirmwareReleaseRemoteDataSource.kt Removed withContext wrapper from Ktor API call
core/network/.../DeviceHardwareRemoteDataSource.kt Removed withContext wrapper from Ktor API call
core/database/.../DatabaseManager.kt Removed withContext(IO) from buildRoomDb call (problematic)
core/data/.../TracerouteSnapshotRepository.kt Used withTransaction, removed flowOn, changed mapLatest to map
core/data/.../QuickChatActionRepository.kt Removed withContext and flowOn for DAO operations
core/data/.../PacketRepository.kt Removed withContext wrappers from all DAO suspend calls
core/data/.../NodeRepository.kt Removed withContext and flowOn from DAO operations
core/data/.../MeshLogRepository.kt Removed flowOn, refactored telemetry parsing with dispatcher switching
core/data/.../DeviceHardwareRepository.kt Removed withContext(IO) wrapper, restructured control flow
core/data/.../CustomTileProviderRepository.kt Changed Dispatchers.IO to default for DataStore write (incorrect)
core/data/.../SwitchingNodeInfoWriteDataSource.kt Removed withContext from DAO operations
core/data/.../FirmwareReleaseLocalDataSource.kt Removed withContext, improved logic with maxByOrNull
core/data/.../DeviceHardwareLocalDataSource.kt Removed withContext from DAO operations
app/.../ContactsViewModel.kt Removed runBlocking from PagingData transformation (critical issue)
app/.../PacketHandler.kt Changed scope from IO to Default (problematic for I/O service)
app/.../MeshService.kt Changed scope from IO to Default (problematic for I/O service)
app/.../RadioInterfaceService.kt Changed scopes from IO to Default (critical issue for radio I/O)
app/.../NetworkRepository.kt Removed flowOn from callback flows
Comments suppressed due to low confidence (1)

core/data/src/google/kotlin/org/meshtastic/core/data/repository/CustomTileProviderRepository.kt:106

  • Changing from Dispatchers.IO to dispatchers.default for DataStore preferences write is incorrect. DataStore operations (mapTileProviderPrefs.customTileProviders = jsonString) are already main-safe and handle their own threading. The withContext wrapper is unnecessary entirely and should be removed, not changed to a different dispatcher. JSON serialization here is lightweight and doesn't require dispatcher switching.
    private suspend fun saveDataToPrefs(providers: List<CustomTileProviderConfig>) {
        withContext(dispatchers.default) {
            try {
                val jsonString = json.encodeToString(providers)
                mapTileProviderPrefs.customTileProviders = jsonString
            } catch (e: SerializationException) {
                Logger.e(e) { "Error serializing tile providers" }
            }
        }

Comment on lines +163 to +164
unreadCount = packetRepository.getUnreadCount(contactKey),
messageCount = packetRepository.getMessageCount(contactKey),

Copilot AI Jan 3, 2026

Copy link

Choose a reason for hiding this comment

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

Calling suspend functions synchronously inside a Paging transformation is problematic. The packetRepository.getUnreadCount(contactKey) and packetRepository.getMessageCount(contactKey) calls are suspend functions being called within a map transformation on PagingData. This will execute on the main thread by default and block it.

The original code using runBlocking(Dispatchers.IO) was actually necessary here to avoid blocking the main thread, even though it's not ideal. A better solution would be to preload this data or restructure the data flow to avoid these synchronous calls inside the paging transformation.

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +57
withContext(dispatchers.default) { // CPU-intensive operation, so run on default dispatcher
parseTelemetryLogActual(log)
}

Copilot AI Jan 3, 2026

Copy link

Choose a reason for hiding this comment

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

The comment "CPU-intensive operation" is misleading. Protobuf parsing (Telemetry.parseFrom) and the builder operations are lightweight operations that don't require switching dispatchers. The original code didn't use any dispatcher switching for this function, and adding withContext(dispatchers.default) here introduces unnecessary dispatcher switching overhead. This should be a simple synchronous function call as it was originally.

Suggested change
withContext(dispatchers.default) { // CPU-intensive operation, so run on default dispatcher
parseTelemetryLogActual(log)
}
parseTelemetryLogActual(log)

Copilot uses AI. Check for mistakes.

private var queueJob: Job? = null
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO)
private var scope: CoroutineScope = CoroutineScope(Dispatchers.Default)

Copilot AI Jan 3, 2026

Copy link

Choose a reason for hiding this comment

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

Changing the PacketHandler scope from Dispatchers.IO to Dispatchers.Default may be problematic. This scope is used for packet processing which likely includes database operations (via meshLogRepository, packetRepository) and potentially network I/O. While the PR description says to avoid wrapping Room DAO calls, this is a background service scope that coordinates I/O operations. Dispatchers.Default is optimized for CPU-bound work, not I/O operations that this handler performs. The original Dispatchers.IO was likely appropriate here.

Suggested change
private var scope: CoroutineScope = CoroutineScope(Dispatchers.Default)
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO)

Copilot uses AI. Check for mistakes.

private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
private val serviceScope = CoroutineScope(Dispatchers.Default + serviceJob)

Copilot AI Jan 3, 2026

Copy link

Choose a reason for hiding this comment

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

Changing the MeshService scope from Dispatchers.IO to Dispatchers.Default is questionable. This is a long-running Android service that coordinates mesh network operations including database access, radio communication, and location services. Services that perform I/O operations (database, network, radio) should typically use Dispatchers.IO to avoid blocking the limited thread pool of Dispatchers.Default which is optimized for CPU-intensive work. This change could impact service performance and responsiveness.

Suggested change
private val serviceScope = CoroutineScope(Dispatchers.Default + serviceJob)
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)

Copilot uses AI. Check for mistakes.

/** We recreate this scope each time we stop an interface */
var serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
var serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())

Copilot AI Jan 3, 2026

Copy link

Choose a reason for hiding this comment

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

Changing RadioInterfaceService scope from Dispatchers.IO to Dispatchers.Default is problematic. This service manages radio communication interfaces (Bluetooth, TCP/IP) which are fundamentally I/O operations. The service reads and writes data to/from radio devices, which requires I/O dispatcher threads. Using Dispatchers.Default (optimized for CPU-bound work) for I/O operations can lead to thread pool exhaustion and poor performance.

Suggested change
var serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
var serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Here, a dispatcher is needed only to have any dispatcher at all. But the default is more suitable here for two reasons: it consumes fewer resources and better expresses what we want to achieve, namely "use any dispatcher at all." If an IO dispatcher remained here, it might be misleading: as if we were using blocking operations somewhere. But that's not the case.

val db =
dbCache[dbName]
?: withContext(Dispatchers.IO) { buildRoomDb(app, dbName) }.also { dbCache[dbName] = it }
val db = dbCache[dbName] ?: buildRoomDb(app, dbName).also { dbCache[dbName] = it }

Copilot AI Jan 3, 2026

Copy link

Choose a reason for hiding this comment

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

Removing withContext(Dispatchers.IO) from buildRoomDb call is problematic. Building/opening a Room database with RoomDatabase.Builder.build() performs blocking file I/O operations including opening SQLite database files, running migrations, and validating schemas. This should remain on Dispatchers.IO to avoid blocking the main thread or the calling coroutine's thread. The original code was correct.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The database builder is not blocking or IO call.

@jamesarich

Copy link
Copy Markdown
Collaborator

Hi @romansl , this PR seems pretty AI generated and wrong in most of the changes.

Please clean it up and provide a human generated reason why this is necessary.

@romansl

romansl commented Jan 3, 2026

Copy link
Copy Markdown
Contributor Author

Hi @romansl , this PR seems pretty AI generated and wrong in most of the changes.

Please clean it up and provide a human generated reason why this is necessary.

Yeah, AI wrote this comment based on my IDE and AI session. But it really captures the main goal of these changes - to bring in best practices for working with coroutines.

This PR (just like the last one) is kind of prep work - all about making the app more reliable and responsive. Next up, I’ll be focusing on best practices for error handling.

@jamesarich

Copy link
Copy Markdown
Collaborator

Unfortunately the AI is wrong. Give it another try.

@jamesarich

Copy link
Copy Markdown
Collaborator

Hi @romansl , this PR seems pretty AI generated and wrong in most of the changes.

Please clean it up and provide a human generated reason why this is necessary.

Yeah, AI wrote this comment based on my IDE and AI session. But it really captures the main goal of these changes - to bring in best practices for working with coroutines.

This PR (just like the last one) is kind of prep work - all about making the app more reliable and responsive. Next up, I’ll be focusing on best practices for error handling.

Before you work on much more, I would encourage you to reach out to the team on discord. We can get you further looped into our dev plans and process discussions.

Appreciate your help, just want to make sure we maintain good communication.

@romansl

romansl commented Jan 3, 2026

Copy link
Copy Markdown
Contributor Author

Unfortunately the AI is wrong. Give it another try.

Ok. Is this better?

@jamesarich

Copy link
Copy Markdown
Collaborator

Unfortunately the AI is wrong. Give it another try.

Ok. Is this better?

Unfortunately, no. I don't see any changes, just some justification for the AI. Also, the build is failing.

@romansl

romansl commented Jan 3, 2026

Copy link
Copy Markdown
Contributor Author

Unfortunately the AI is wrong. Give it another try.

Ok. Is this better?

Unfortunately, no. I don't see any changes, just some justification for the AI. Also, the build is failing.

I didn't really understand what's wrong. If the problem is in the code, then highlight the specific place with a comment.

@jamesarich

Copy link
Copy Markdown
Collaborator

Sorry, it's not on me to prove your code is bad, it's on you to prove your code is NOT bad.

@romansl

romansl commented Jan 3, 2026

Copy link
Copy Markdown
Contributor Author

The build is failing because Spotless is issuing a code formatting warning. But if I try to run spotlessApply locally, it modifies about 30 files in the project that I didn't even touch. And based on the warning, I can't figure out what it needs me to fix manually.

@jamesarich

Copy link
Copy Markdown
Collaborator

Especially when the PR seems highly AI generated - if the Author cannot explain the changes, why they are necessary, and their impact. This creates a low level of confidence in the submission, and we tend to be pretty leery of merging in suspect code that we then have to maintain.

@romansl

romansl commented Jan 3, 2026

Copy link
Copy Markdown
Contributor Author

Sorry, it's not on me to prove your code is bad, it's on you to prove your code is NOT bad.

The AI only modified 4 files (repositories), simply by removing withContext. All other changes I made manually.

@jamesarich

Copy link
Copy Markdown
Collaborator

Sorry, it's not on me to prove your code is bad, it's on you to prove your code is NOT bad.

The AI only modified 4 files (repositories), simply by removing withContext. All other changes I made manually.

Great, then you should be able to explain the changes and their necessity.

@romansl

romansl commented Jan 3, 2026

Copy link
Copy Markdown
Contributor Author

Great, then you should be able to explain the changes and their necessity.

The main idea is to use withContext only to make a suspend function non-blocking.

Please read the article by Roman Elizarov I mentioned.

These changes are necessary to reduce resource consumption and lower code complexity by removing unnecessary calls that are not beneficial and even harmful.

@jamesarich jamesarich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I’ve audited this locally and reviewed the documentation linked. This PR is unmerge-able as it currently introduces some dangerous architectural regressions.

While Room is main-safe for raw DAO hits, our repositories have to be main-safe for the entire operation . Stripping withContext(IO) indiscriminately pushes O(N) work—like the chunking in PacketRepository and the node filtering in NodeRepository—directly onto the UI thread. This will cause ANRs once a user has any real data.

The article linked actually reinforces the need for the repository to be main-safe so the caller doesn't have to worry about threading.

Keep the suspending getString changes in the ViewModel, but restore the IO dispatchers for any repository method doing more than a single DAO call.

@romansl

romansl commented Jan 6, 2026

Copy link
Copy Markdown
Contributor Author

tripping withContext(IO) indiscriminately pushes O(N) work—like the chunking in PacketRepository and the node filtering in NodeRepository—directly onto the UI thread. This will cause ANRs once a user has any real data.

None of the methods in PacketRepository require an IO dispatcher. Even deleteMessages, since a noticeable lag would only occur with a very large list, which is unlikely to form in this app due to limited memory. But if you still want to protect deleteMessages, you should use the Default dispatcher because the chunked function is CPU-bound, not IO. Also, the optimization can be done like this: uuidList.asSequence().chunked(500).

@jamesarich

jamesarich commented Jan 6, 2026

Copy link
Copy Markdown
Collaborator

Closing this as no progress has been made towards resolving requested changes.

Instead of a large untested blanket arch change, maybe try again with small focused individual PRs, with tests and proof of optimization.

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