Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import zed.rainxch.core.data.di.networkModule
import zed.rainxch.details.data.di.detailsModule
import zed.rainxch.devprofile.data.di.devProfileModule
import zed.rainxch.home.data.di.homeModule
import zed.rainxch.profile.data.di.settingsModule
import zed.rainxch.profile.data.di.profileModule
import zed.rainxch.search.data.di.searchModule

fun initKoin(config: KoinAppDeclaration? = null) {
Expand All @@ -30,7 +30,7 @@ fun initKoin(config: KoinAppDeclaration? = null) {
devProfileModule,
homeModule,
searchModule,
settingsModule,
profileModule,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import zed.rainxch.core.data.network.ProxyManager
import zed.rainxch.core.data.network.resolveAndroidSystemProxy
import zed.rainxch.core.domain.model.DownloadProgress
import zed.rainxch.core.domain.model.ProxyConfig
import zed.rainxch.core.domain.model.ProxyScope
import zed.rainxch.core.domain.network.Downloader
import java.io.File
import java.net.Authenticator
Expand All @@ -26,7 +27,6 @@ import java.util.concurrent.TimeUnit

class AndroidDownloader(
private val files: FileLocationsProvider,
private val proxyManager: ProxyManager = ProxyManager,
) : Downloader {
private val activeDownloads = ConcurrentHashMap<String, Call>()
private val activeFileNames = ConcurrentHashMap<String, String>()
Expand All @@ -40,7 +40,7 @@ class AndroidDownloader(
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.apply {
when (val config = proxyManager.currentProxyConfig.value) {
when (val config = ProxyManager.currentConfig(ProxyScope.DOWNLOAD)) {
is ProxyConfig.None -> {
proxy(Proxy.NO_PROXY)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package zed.rainxch.core.data.di

import io.ktor.client.HttpClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
Expand All @@ -24,8 +26,9 @@ import zed.rainxch.core.data.logging.KermitLogger
import zed.rainxch.core.data.network.BackendApiClient
import zed.rainxch.core.data.network.GitHubClientProvider
import zed.rainxch.core.data.network.ProxyManager
import zed.rainxch.core.data.network.ProxyManagerSeeding
import zed.rainxch.core.data.network.ProxyTesterImpl
import zed.rainxch.core.data.network.createGitHubHttpClient
import zed.rainxch.core.data.network.TranslationClientProvider
import zed.rainxch.core.data.repository.AuthenticationStateImpl
import zed.rainxch.core.data.repository.FavouritesRepositoryImpl
import zed.rainxch.core.data.repository.InstalledAppsRepositoryImpl
Expand All @@ -41,6 +44,7 @@ import zed.rainxch.core.domain.getPlatform
import zed.rainxch.core.domain.logging.GitHubStoreLogger
import zed.rainxch.core.domain.model.Platform
import zed.rainxch.core.domain.model.ProxyConfig
import zed.rainxch.core.domain.model.ProxyScope
import zed.rainxch.core.domain.network.ProxyTester
import zed.rainxch.core.domain.system.DownloadOrchestrator
import zed.rainxch.core.domain.repository.AuthenticationState
Expand Down Expand Up @@ -89,7 +93,7 @@ val coreModule =
installedAppsDao = get(),
historyDao = get(),
installer = get(),
httpClient = get(),
clientProvider = get(),
)
}

Expand All @@ -98,7 +102,7 @@ val coreModule =
installedAppsDao = get(),
starredRepoDao = get(),
platform = get(),
httpClient = get(),
clientProvider = get(),
)
}

Expand All @@ -123,6 +127,7 @@ val coreModule =
single<ProxyRepository> {
ProxyRepositoryImpl(
preferences = get(),
logger = get(),
)
}

Expand All @@ -144,8 +149,24 @@ val coreModule =
}

single<BackendApiClient> {
BackendApiClient()
// Request the seeding sentinel so Koin guarantees ProxyManager
// has the user's persisted config loaded before we snapshot
// the discovery flow for the initial client build.
get<ProxyManagerSeeding>()
BackendApiClient(
proxyConfigFlow = ProxyManager.configFlow(ProxyScope.DISCOVERY),
)
}
// NOTE: the reviewer asked for a Koin onClose hook to call
// BackendApiClient.close()/GitHubClientProvider.close()/
// TranslationClientProvider.close() at Koin shutdown. Koin 4.x
// (4.1.1 on this project) doesn't expose that hook at the
// module DSL level — it existed in 3.x and was removed — and
// there's no clean replacement short of wrapping each provider
// in a Koin scope. On Android/Desktop the process exit
// releases these resources anyway, so we intentionally leave
// the hooks off rather than fake them with an API that doesn't
// fit. Revisit if we upgrade Koin or adopt scope-based DI.

single<DeviceIdentityRepository> {
DeviceIdentityRepositoryImpl(
Expand Down Expand Up @@ -180,58 +201,53 @@ val coreModule =

val networkModule =
module {
single<GitHubClientProvider> {
val config =
runBlocking {
runCatching {
withTimeout(1_500L) {
get<ProxyRepository>().getProxyConfig().first()
// Seed [ProxyManager] from persisted per-scope configs *before*
// any HTTP client is constructed. Registered as its own
// [ProxyManagerSeeding] sentinel so client providers can depend
// on it explicitly — without this the seeding would live inside
// one provider's factory and silently race with others.
//
// Reads run in parallel under a single 1.5s budget (was 1.5s × 3
// sequential). On timeout / DataStore failure we fall back to the
// in-memory defaults — we'd rather the app network with the
// System proxy than stall at launch on a slow disk.
single<ProxyManagerSeeding>(createdAtStart = true) {
val repository = get<ProxyRepository>()
runBlocking {
runCatching {
withTimeout(1_500L) {
coroutineScope {
ProxyScope.entries
.map { scope ->
async {
scope to repository.getProxyConfig(scope).first()
}
}.awaitAll()
}
}.getOrDefault(ProxyConfig.System)
}

when (config) {
is ProxyConfig.None -> {
ProxyManager.setNoProxy()
}

is ProxyConfig.System -> {
ProxyManager.setSystemProxy()
}

is ProxyConfig.Http -> {
ProxyManager.setHttpProxy(
host = config.host,
port = config.port,
username = config.username,
password = config.password,
)
}

is ProxyConfig.Socks -> {
ProxyManager.setSocksProxy(
host = config.host,
port = config.port,
username = config.username,
password = config.password,
)
}
}.onSuccess { results ->
results.forEach { (scope, config) ->
ProxyManager.setConfig(scope, config)
}
}
}
Comment on lines +214 to 233
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt | sed -n '200,240p'

Repository: OpenHub-Store/GitHub-Store

Length of output: 2077


🏁 Script executed:

# Check the package declaration and overall file structure
head -20 core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt

Repository: OpenHub-Store/GitHub-Store

Length of output: 917


🏁 Script executed:

# Look for ProxyConfig class to verify the default value
rg "class ProxyConfig|object ProxyConfig" -A 5

Repository: OpenHub-Store/GitHub-Store

Length of output: 669


🏁 Script executed:

# Check if this coroutineScope has SupervisorJob context
sed -n '214,235p' core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt

Repository: OpenHub-Store/GitHub-Store

Length of output: 939


🏁 Script executed:

# Verify that onSuccess is indeed the only place ProxyManager.setConfig is called in this context
rg "ProxyManager\.setConfig" -B 5 -A 2

Repository: OpenHub-Store/GitHub-Store

Length of output: 1894


🏁 Script executed:

# Check if there's any top-level supervision in the Koin module
sed -n '202,250p' core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt

Repository: OpenHub-Store/GitHub-Store

Length of output: 2207


Isolate seeding failures per proxy scope.

One failed getProxyConfig(scope).first() causes awaitAll() to throw, the runCatching catches it, and onSuccess never executes—leaving all scopes unconfigured (not even the in-memory defaults are applied). This contradicts the comment's intent to fall back to System proxy on failure. Failures should be caught per async task before awaiting so successful scopes are still applied.

🛡️ Proposed fix to preserve successful scope reads
             runBlocking {
                 runCatching {
                     withTimeout(1_500L) {
                         coroutineScope {
                             ProxyScope.entries
                                 .map { scope ->
                                     async {
-                                        scope to repository.getProxyConfig(scope).first()
+                                        val config =
+                                            runCatching {
+                                                repository.getProxyConfig(scope).first()
+                                            }.getOrDefault(ProxyConfig.System)
+
+                                        scope to config
                                     }
                                 }.awaitAll()
                         }
                     }
                 }.onSuccess { results ->
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt`
around lines 214 - 233, The seeding currently wraps the whole awaitAll in
runCatching so one failing getProxyConfig(scope).first() cancels all tasks;
change the async block inside the coroutineScope (within ProxyManagerSeeding) to
catch exceptions per scope: in the map { async { ... } } return a safe wrapper
like Result or Pair<ProxyScope, ProxyConfig?> (e.g., try { scope to
repository.getProxyConfig(scope).first() } catch(e: Throwable) { scope to null }
), then awaitAll(), filter out null configs, and call
ProxyManager.setConfig(scope, config) only for successful pairs so one bad scope
won't prevent others from being applied. Ensure you still respect the existing
withTimeout and propagate/log individual errors as needed.

ProxyManagerSeeding()
}

single<GitHubClientProvider>(createdAtStart = true) {
get<ProxyManagerSeeding>()
GitHubClientProvider(
tokenStore = get(),
rateLimitRepository = get(),
authenticationState = get(),
proxyConfigFlow = ProxyManager.currentProxyConfig,
proxyConfigFlow = ProxyManager.configFlow(ProxyScope.DISCOVERY),
)
}

single<HttpClient> {
createGitHubHttpClient(
tokenStore = get(),
rateLimitRepository = get(),
authenticationState = get(),
scope = get(),
single<TranslationClientProvider>(createdAtStart = true) {
get<ProxyManagerSeeding>()
TranslationClientProvider(
proxyConfigFlow = ProxyManager.configFlow(ProxyScope.TRANSLATION),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,70 @@ import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import zed.rainxch.core.data.dto.BackendExploreResponse
import zed.rainxch.core.data.dto.BackendRepoResponse
import zed.rainxch.core.data.dto.BackendSearchResponse
import zed.rainxch.core.data.dto.EventRequest
import zed.rainxch.core.domain.model.ProxyConfig
import kotlin.coroutines.cancellation.CancellationException

class BackendApiClient {
/**
* Client for GitHub Store's own backend (trending/popular/search).
* Treated as *discovery* traffic — routes through the discovery-scope
* proxy so users configuring a proxy for GitHub browsing also have
* their backend discovery requests proxied consistently.
*/
class BackendApiClient(
proxyConfigFlow: StateFlow<ProxyConfig>,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val mutex = Mutex()

private val httpClient = HttpClient {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(HttpTimeout) {
requestTimeoutMillis = 5_000
connectTimeoutMillis = 3_000
socketTimeoutMillis = 5_000
}
defaultRequest {
url(BASE_URL)
@Volatile
private var httpClient: HttpClient = buildClient(proxyConfigFlow.value)

init {
proxyConfigFlow
.drop(1)
.distinctUntilChanged()
.onEach { config ->
mutex.withLock {
httpClient.close()
httpClient = buildClient(config)
}
}.launchIn(scope)
}

private fun buildClient(proxyConfig: ProxyConfig): HttpClient =
createPlatformHttpClient(proxyConfig).config {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(HttpTimeout) {
requestTimeoutMillis = 5_000
connectTimeoutMillis = 3_000
socketTimeoutMillis = 5_000
}
defaultRequest {
url(BASE_URL)
}
expectSuccess = false
}
expectSuccess = false

fun close() {
httpClient.close()
scope.cancel()
}
Comment on lines +80 to 83
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.

⚠️ Potential issue | 🟡 Minor

close() is defined but never invoked via Koin.

BackendApiClient owns a CoroutineScope and an HttpClient but its DI registration (single<BackendApiClient> { … } in SharedModule.kt lines 146-150) has no onClose callback, so on Koin shutdown/test teardown the coroutine subscriber and underlying engine leak. Consider wiring a teardown hook.

🔒 Proposed fix in SharedModule.kt
-        single<BackendApiClient> {
-            BackendApiClient(
-                proxyConfigFlow = ProxyManager.configFlow(ProxyScope.DISCOVERY),
-            )
-        }
+        single<BackendApiClient> {
+            BackendApiClient(
+                proxyConfigFlow = ProxyManager.configFlow(ProxyScope.DISCOVERY),
+            )
+        } onClose { it?.close() }

Same treatment would be appropriate for the GitHubClientProvider and TranslationClientProvider singletons if not already covered elsewhere.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt`
around lines 80 - 83, The BackendApiClient.close() method is never hooked into
Koin shutdown so its httpClient and scope leak; update the DI registration for
single<BackendApiClient> in SharedModule.kt to provide an onClose callback that
calls BackendApiClient.close() (and do the same for GitHubClientProvider and
TranslationClientProvider singletons if they own resources), ensuring the
created instance is closed on Koin stop/teardown.


suspend fun getCategory(category: String, platform: String): Result<List<BackendRepoResponse>> =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,20 @@ class GitHubClientProvider(
.distinctUntilChanged()
.onEach { proxyConfig ->
mutex.withLock {
currentClient.close()
currentClient =
// Replace-then-close: readers of [client] always see
// a live client. Closing first opens a window where
// an in-flight call could touch a closed engine.
val replacement =
createGitHubHttpClient(
tokenStore = tokenStore,
rateLimitRepository = rateLimitRepository,
authenticationState = authenticationState,
scope = scope,
proxyConfig = proxyConfig,
)
val previous = currentClient
currentClient = replacement
previous.close()
Comment on lines +45 to +58
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find and read the GitHubClientProvider.kt file
find . -name "GitHubClientProvider.kt" -type f

Repository: OpenHub-Store/GitHub-Store

Length of output: 157


🏁 Script executed:

# Also search for TranslationClientProvider.kt
find . -name "TranslationClientProvider.kt" -type f

Repository: OpenHub-Store/GitHub-Store

Length of output: 162


🏁 Script executed:

# Check the structure of the core/data directory
fd --type f --extension kt "core/data" | head -20

Repository: OpenHub-Store/GitHub-Store

Length of output: 411


🏁 Script executed:

# Read the GitHubClientProvider.kt file to verify the code snippet
cat -n ./core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt

Repository: OpenHub-Store/GitHub-Store

Length of output: 3056


🏁 Script executed:

# Read the TranslationClientProvider.kt file to check for similar pattern
cat -n ./core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/TranslationClientProvider.kt

Repository: OpenHub-Store/GitHub-Store

Length of output: 2553


Delay disposal of previous to close the remaining client-swap race.

The Mutex protects the client swap itself, but a thread can read currentClient before the assignment and start a request on it immediately before previous.close() is called. Since Ktor's close() starts asynchronous shutdown, requests initiated just before closure can fail. Implement a drain period before closing to let inflight requests complete.

This same race exists in TranslationClientProvider.kt (lines 45–47) and requires the identical fix.

🛡️ Possible drain-period fix
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
@@
 class GitHubClientProvider(
@@
 ) {
+    private companion object {
+        const val CLIENT_REPLACEMENT_DRAIN_MS = 30_000L
+    }
+
@@
                     val previous = currentClient
                     currentClient = replacement
-                    previous.close()
+                    scope.launch {
+                        try {
+                            delay(CLIENT_REPLACEMENT_DRAIN_MS)
+                        } finally {
+                            previous.close()
+                        }
+                    }
                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Replace-then-close: readers of [client] always see
// a live client. Closing first opens a window where
// an in-flight call could touch a closed engine.
val replacement =
createGitHubHttpClient(
tokenStore = tokenStore,
rateLimitRepository = rateLimitRepository,
authenticationState = authenticationState,
scope = scope,
proxyConfig = proxyConfig,
)
val previous = currentClient
currentClient = replacement
previous.close()
// Replace-then-close: readers of [client] always see
// a live client. Closing first opens a window where
// an in-flight call could touch a closed engine.
val replacement =
createGitHubHttpClient(
tokenStore = tokenStore,
rateLimitRepository = rateLimitRepository,
authenticationState = authenticationState,
scope = scope,
proxyConfig = proxyConfig,
)
val previous = currentClient
currentClient = replacement
scope.launch {
try {
delay(CLIENT_REPLACEMENT_DRAIN_MS)
} finally {
previous.close()
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/GitHubClientProvider.kt`
around lines 45 - 58, The client-swap race occurs because `previous.close()` is
invoked immediately after `currentClient` is replaced (seen in
`GitHubClientProvider` around `currentClient = replacement` and
`previous.close()`), allowing a thread that read the old `currentClient` to
start a request just before closure; fix by deferring disposal: after assigning
`currentClient = replacement`, schedule closing `previous` on the provider's
`scope` (or a short suspend delay/drain period, e.g., launch a coroutine that
delays ~500–1000ms or until an in-flight-counter drops to zero) and then call
`previous.close()` inside that coroutine so existing requests can complete;
apply the identical change in `TranslationClientProvider` (the same
`currentClient`/`previous.close()` swap) and ensure the Mutex protecting the
swap remains unchanged.

}
}.launchIn(scope)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,28 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import zed.rainxch.core.domain.model.ProxyConfig
import zed.rainxch.core.domain.model.ProxyScope

/**
* Live in-memory cache of the three per-scope proxy configurations.
* Writers (the repository) push updates via [setConfig]; consumers
* subscribe to [configFlow] so they rebuild their HTTP clients when
* the user flips a setting mid-session.
*/
object ProxyManager {
private val _proxyConfig = MutableStateFlow<ProxyConfig>(ProxyConfig.System)
val currentProxyConfig: StateFlow<ProxyConfig> = _proxyConfig.asStateFlow()
private val flows: Map<ProxyScope, MutableStateFlow<ProxyConfig>> =
ProxyScope.entries.associateWith { MutableStateFlow<ProxyConfig>(ProxyConfig.System) }

fun setNoProxy() {
_proxyConfig.value = ProxyConfig.None
}

fun setSystemProxy() {
_proxyConfig.value = ProxyConfig.System
}
fun configFlow(scope: ProxyScope): StateFlow<ProxyConfig> =
flows.getValue(scope).asStateFlow()

fun setHttpProxy(
host: String,
port: Int,
username: String? = null,
password: String? = null,
) {
_proxyConfig.value = ProxyConfig.Http(host, port, username, password)
}
fun currentConfig(scope: ProxyScope): ProxyConfig =
flows.getValue(scope).value

fun setSocksProxy(
host: String,
port: Int,
username: String? = null,
password: String? = null,
fun setConfig(
scope: ProxyScope,
config: ProxyConfig,
) {
_proxyConfig.value = ProxyConfig.Socks(host, port, username, password)
flows.getValue(scope).value = config
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package zed.rainxch.core.data.network

/**
* Marker type that represents "the [ProxyManager] has been seeded
* with the user's persisted per-scope proxy configs." Any component
* that constructs an HTTP client whose proxy is read from
* [ProxyManager] at creation time must inject this so Koin resolves
* the seeding step first — it forces the DI graph dependency to be
* explicit instead of depending on registration order.
*/
class ProxyManagerSeeding internal constructor()
Loading