Skip to content

fix(net): run API calls off the caller's dispatcher (#326) - #354

Merged
garfiec merged 1 commit into
developfrom
fix/io-dispatcher-http
Aug 13, 2026
Merged

fix(net): run API calls off the caller's dispatcher (#326)#354
garfiec merged 1 commit into
developfrom
fix/io-dispatcher-http

Conversation

@garfiec

@garfiec garfiec commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Summary

Nothing in the repository layer moved HTTP work off the caller's thread, so whether a response was
handled on the main thread was decided entirely by whichever scope launched the request. Ktor's
OkHttp engine does its socket I/O on engine threads, but the continuation resumes in the caller's
context — so body deserialization, the auth plugin's 401 branch, the token refresh that branch
drives and that refresh's keystore-backed EncryptedSharedPreferences reads all ran wherever the
call started. Several cold-start fetches launch from viewModelScope, which put all of that on the
UI thread while the app was drawing its first frame — /api/banner and /api/config from
NavHostViewModel, /api/convos and /api/projects from DrawerViewModel.

Closes #326.

Changes

The hop goes inside safeApiCall (:core:common), not into each repository as the issue
proposed. There are 148 call sites across the 28 repository impls that use it, and only 4 of those
inject a dispatcher today, so per-repository would mean 24 new constructor parameters plus the DI
and test churn — and it would stay forgettable for the next repository added. A Ktor plugin was the
other candidate: it would have to cover two pipelines rather than one — the send, and the response
pipeline that body() drives — and it still would not cover the Room and DataStore work sitting in
the same block as the call.

iOS: the io qualifier moves from Dispatchers.Default to Dispatchers.IO. Routing every API
call through ioDispatcher is what makes that matter. On Native, Dispatchers.Default is a worker
pool sized to the core count and shared with applicationScope, so without this the change would
park every CPU worker on a socket during a cold-start fan-out — a regression on iOS in exchange for
the Android fix. Native's Dispatchers.IO is a separate pool, and Room's iOS query context already
uses it.

The hop wraps the whole safeApiCall block rather than only its network call, so a block that also
touches Room or DataStore now does those reads and writes on the IO dispatcher too. That is a
widening beyond the title, and it is the right direction — those were the other things running on
the caller's thread.

onApiDispatcher is the same hop without the error mapping. Nine API invocations across five
repositories don't go through safeApiCall — they read a server validation message off the response
body, fall back to a cached value, make a first attempt whose failure is expected, or throw rather
than returning a Result — and routing those through safeApiCall would put a Logger.e on a path
that fails by design. They take the hop explicitly instead: withContext(dispatcher) where the
repository already injects one (ChatRepositoryImpl.checkStreamStatus,
ConfigRepositoryImpl.fetchAndValidateConfig), onApiDispatcher where it does not
(FileRepositoryImpl.downloadFile, RoleRepositoryImpl.fetchUserRole, and skill create / update /
upload / import).

Documentation. core/common/CLAUDE.md's safeApiCall snippet was fictional — it showed catches
for ClientRequestException / ServerResponseException / IOException with hardcoded strings that
the function has never had — and is now the real implementation plus the reason the hop lives there.
core/data/CLAUDE.md gains the invariant: every :core:network API-service invocation sits inside
safeApiCall, inside onApiDispatcher, or inside a flow that ends in .flowOn(dispatcher).

Streaming already satisfied it. A stale comment in CommonTokenDataStore that still described the
proactive-renewal path as running on the UI thread is corrected.

Testing

Three new tests in SafeApiCallTest; two of them pin the hop on the ContinuationInterceptor rather
than on a thread name, so they assert the same thing on both platforms, and runTest installs a
dispatcher of its own, so removing the hop fails them rather than passing by coincidence.

Full unit suite: 2070 tests, 0 failures. No existing test needed a dispatcher override, so no
test-only seam was added to production code.

./gradlew testDebugUnitTest :app:assembleDebug detektMetadataCommonMain passes.
:core:common:compileKotlinIosSimulatorArm64 passes for the iOS dispatcher change. :core:common's
iOS test compile fails on a pre-existing illegal test-method name in SafeErrorMessageTest.kt,
untouched by this branch. No iOS device or simulator run.

Coverage was checked mechanically rather than by inspection: every *Api.method(...) invocation in
:core:data was walked back to its enclosing function and required to sit inside safeApiCall,
inside onApiDispatcher, or inside a flow ending in .flowOn(dispatcher). Zero uncovered remain.

Device pass

Pixel 10 Pro Fold AVD (API 37) against a local LibreChat server, replaying one snapshotted
dead-session cold start so both builds see identical state. Each build fingerprinted by dex symbol
before its run, since the emulator is shared. StrictMode's thread policy was armed temporarily
(detectDiskReads / detectDiskWrites / detectCustomSlowCalls / detectNetwork, penaltyLog) —
instrumentation only, not committed.

Main-thread log lines, counted by TID == PID in logcat -v threadtime:

develop (5738299) this branch
401 received on main 10 of 19 0 of 19
refresh_rejected on main 2 of 11 0 of 21
refresh_proactive on main 2 of 7 0 of 18

The baseline reproduces the issue's original measurement (it reported 11 of 16). Both criteria pass.

StrictMode reported 11 main-thread violations on both builds, and on both every one attributes to
TokenDataStore.createEncryptedPrefs — the MasterKey / EncryptedSharedPreferences construction at
Koin startup — plus one cacheDir read in DataPlatformModule. None come from the auth or HTTP
path on either build
, which is the criterion; the construction-time ones are the out-of-scope item
noted below, and this change neither fixes nor worsens them.

Outcome behaviour is unchanged: 19 refresh legs entered, 19 settled session expired, exactly one
session_torn_down, and the same NewChat → ServerUrl screen sequence — so #323's
one-teardown-per-session behaviour is preserved. The one delta is that more refresh POSTs complete
inside the 22 s capture window (11 → 21) with the same number of refresh legs entered; the retry loops
now actually run instead of being starved behind a blocked main thread.

Notes

  • safeApiCall bodies no longer run on the test dispatcher, so a mocked delay() inside one is now
    real time rather than virtual (two 50 ms sleeps in RepositoryCacheMutexTest). The shape that
    would actually break — launch { repo.call() }, advanceUntilIdle(), assert — appears in none of
    the 14 test files that construct a real repository, so no test-only dispatcher override was added
    to production code.
  • Out of scope, and untouched: EncryptedSharedPreferences.create and the initial token decrypt
    still run on the main thread during TokenDataStore construction at startKoin. That is the
    largest remaining item in the 401 handling and token refresh run on the Main thread during cold start #326 family; it is already flagged in a comment in
    NavHostViewModel, and it isn't reachable from this seam because it happens before any request.
  • CommonTokenDataStore's token-refresh POST is the one HTTP call in :core:data that isn't an
    API-service method and so isn't covered by the invariant above. It is always driven from inside a
    request that already took the hop.

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Android debug APK

Artifact: switchboard-android-debug-354
Download: switchboard-android-debug-354.zip
Retention: 90 days
Commit: db0876b43dd7cba5cf906312ca4f84aaa53c2ea7

Download requires a GitHub login. Installs over previous debug builds without uninstalling (stable signing key).

Nothing in the repository layer moved HTTP work off the caller's thread, so
whether a response was handled on Main was decided by whichever scope launched
the request. Ktor's engine does its socket I/O on engine threads, but the
continuation resumes in the caller's context -- so body deserialization, the
auth plugin's 401 branch, the token refresh it drives and that refresh's
keystore-backed EncryptedSharedPreferences reads all ran wherever the call
started. Six cold-start call sites launch from viewModelScope, which put all of
that on the UI thread while the app was drawing its first frame.

The hop goes inside safeApiCall rather than into each repository. There are 148
call sites across the 28 repository impls that use it, and only 4 of those
inject a dispatcher today -- so the per-repository version is 24 new constructor
parameters plus the DI and test churn, and it stays forgettable for the next
repository added. A Ktor plugin was the other
candidate: it would have to cover two pipelines rather than one -- the send, and
the response pipeline that body() drives -- and it still would not cover the Room
and DataStore work sitting in the same block as the call.

The iOS ioDispatcher actual moves from Dispatchers.Default to Dispatchers.IO.
Routing every API call through it makes that matter: on Native, Default is a
worker pool sized to the core count and shared with applicationScope, so a
cold-start fan-out would park every CPU worker on a socket. Native's IO is a
separate pool; Room's iOS query context already uses it.

Nine API invocations across five repositories do not go through safeApiCall
because they classify their own failures -- a server validation message read off
the response, a fallback to a cached value, or a first attempt whose failure is
expected on a server with no CDN. Routing those through safeApiCall would log an
expected failure at error level, so they take the hop explicitly:
withContext(dispatcher) where the repository already injects one, and
onApiDispatcher (the same hop without the mapping) where it does not.

The invariant to hold from here: every API invocation sits inside safeApiCall,
inside onApiDispatcher, or inside a flow that ends in flowOn. Streaming already
satisfied it.
@garfiec
garfiec force-pushed the fix/io-dispatcher-http branch from d420146 to db0876b Compare August 13, 2026 07:10
@garfiec
garfiec merged commit 7d4c1e8 into develop Aug 13, 2026
6 checks passed
@garfiec
garfiec deleted the fix/io-dispatcher-http branch August 13, 2026 07:26
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.

401 handling and token refresh run on the Main thread during cold start

1 participant