2 Commits

Author SHA1 Message Date
zarazaex69 a04c53a045 fix: real ping IPC stalls by batching and throttling UI updates 2026-04-21 04:27:37 +03:00
zarazaex69 278095015b feat: remove apk 2026-04-21 03:06:16 +03:00
8 changed files with 114 additions and 134 deletions
+25 -68
View File
@@ -170,9 +170,7 @@ func (x *CoreController) MeasureDelay(url string) (int64, error) {
// MeasureOutboundDelay measures the outbound delay for a given configuration and URL
func MeasureOutboundDelay(ConfigureFileContent string, url string) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
return measureOutboundDelayInternal(ctx, ConfigureFileContent, url)
return measureOutboundDelayInternal(ConfigureFileContent, url)
}
// MeasureOutboundDelayBatch measures the outbound delay for multiple configurations in parallel
@@ -192,46 +190,28 @@ func MeasureOutboundDelayBatch(itemsJson string, url string, callback PingCallba
return
}
// Use a worker pool to process items
// Performance tuning: 24 concurrency
concurrency := 24
if len(items) < concurrency {
concurrency = len(items)
}
itemChan := make(chan PingItem, len(items))
for _, item := range items {
itemChan <- item
}
close(itemChan)
// Semaphore to limit concurrency (max 128 concurrent tests)
sem := make(chan struct{}, 128)
var wg sync.WaitGroup
wg.Add(concurrency)
for i := 0; i < concurrency; i++ {
go func() {
for _, item := range items {
wg.Add(1)
go func(it PingItem) {
defer wg.Done()
for it := range itemChan {
// Set a reasonable timeout for each individual test
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
delay, _ := measureOutboundDelayInternal(ctx, it.Config, url)
cancel() // cancel context as soon as we have a result
sem <- struct{}{}
defer func() { <-sem }()
if callback != nil {
callback.OnResult(it.Guid, delay)
}
// Small sleep to prevent system congestion
time.Sleep(10 * time.Millisecond)
delay, _ := measureOutboundDelayInternal(it.Config, url)
if callback != nil {
callback.OnResult(it.Guid, delay)
}
}()
}(item)
}
wg.Wait()
}
func measureOutboundDelayInternal(ctx context.Context, ConfigureFileContent string, url string) (int64, error) {
func measureOutboundDelayInternal(ConfigureFileContent string, url string) (int64, error) {
config, err := coreserial.LoadJSONConfig(strings.NewReader(ConfigureFileContent))
if err != nil {
return -1, fmt.Errorf("config load error: %w", err)
@@ -257,28 +237,8 @@ func measureOutboundDelayInternal(ctx context.Context, ConfigureFileContent stri
if err := inst.Start(); err != nil {
return -1, fmt.Errorf("startup failed: %w", err)
}
// Measure delay
delay, err := measureInstDelay(ctx, inst, url)
// Close instance with a short timeout to prevent hanging the worker
closeCtx, closeCancel := context.WithTimeout(context.Background(), 2*time.Second)
defer closeCancel()
done := make(chan struct{})
go func() {
inst.Close()
close(done)
}()
select {
case <-done:
// Closed successfully
case <-closeCtx.Done():
// Close timed out, move on
}
return delay, err
defer inst.Close()
return measureInstDelay(context.Background(), inst, url)
}
// CheckVersionX returns the library and Xray versions
@@ -335,7 +295,7 @@ func measureInstDelay(ctx context.Context, inst *core.Instance, url string) (int
tr := &http.Transport{
TLSHandshakeTimeout: 6 * time.Second,
DisableKeepAlives: true,
DisableKeepAlives: false,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
dest, err := corenet.ParseDestination(fmt.Sprintf("%s:%s", network, addr))
if err != nil {
@@ -347,7 +307,7 @@ func measureInstDelay(ctx context.Context, inst *core.Instance, url string) (int
client := &http.Client{
Transport: tr,
Timeout: 6 * time.Second,
Timeout: 5 * time.Second,
}
if url == "" {
@@ -363,7 +323,7 @@ func measureInstDelay(ctx context.Context, inst *core.Instance, url string) (int
success := false
var lastErr error
// Use 2 attempts
// Use 2 attempts as requested by user
const attempts = 2
for i := 0; i < attempts; i++ {
select {
@@ -384,8 +344,8 @@ func measureInstDelay(ctx context.Context, inst *core.Instance, url string) (int
continue
}
// Limit reading to 64KB to prevent OOM
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
// Read body and close resp immediately
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
@@ -398,14 +358,11 @@ func measureInstDelay(ctx context.Context, inst *core.Instance, url string) (int
continue
}
// Relaxed IP check: only if the URL seems like an IP check service
isIPCheckUrl := strings.Contains(strings.ToLower(url), "ip")
if isIPCheckUrl {
ipStr := strings.TrimSpace(string(body))
if net.ParseIP(ipStr) == nil {
lastErr = fmt.Errorf("response body is not a valid IP: %s", ipStr)
continue
}
// Strict IP check: the body must contain a valid IP address
ipStr := strings.TrimSpace(string(body))
if net.ParseIP(ipStr) == nil {
lastErr = fmt.Errorf("response body is not a valid IP: %s", ipStr)
continue
}
duration := time.Since(start).Milliseconds()
@@ -164,6 +164,7 @@ object AppConfig {
const val MSG_MEASURE_CONFIG_CANCEL = 72
const val MSG_MEASURE_CONFIG_NOTIFY = 73
const val MSG_MEASURE_CONFIG_FINISH = 74
const val MSG_MEASURE_CONFIG_BATCH = 75
/** Notification channel IDs and names. */
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
@@ -0,0 +1,14 @@
package xyz.zarazaex.olc.dto
import java.io.Serializable
data class PingResultItem(
val guid: String,
val delay: Long
) : Serializable
data class PingProgressUpdate(
val results: ArrayList<PingResultItem>,
val finished: Int,
val total: Int
) : Serializable
@@ -42,18 +42,8 @@ open class FmtBase {
* @return a map of query parameters
*/
fun getQueryParam(uri: URI): Map<String, String> {
return uri.rawQuery.orEmpty().split("&")
.mapNotNull {
val parts = it.split("=", limit = 2)
if (parts.size == 2) {
parts[0] to Utils.decodeURIComponent(parts[1])
} else if (parts.isNotEmpty() && parts[0].isNotEmpty()) {
parts[0] to ""
} else {
null
}
}
.toMap()
return uri.rawQuery.split("&")
.associate { it.split("=").let { (k, v) -> k to Utils.decodeURIComponent(v) } }
}
/**
@@ -6,8 +6,12 @@ import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.dto.PingProgressUpdate
import xyz.zarazaex.olc.dto.PingResultItem
import xyz.zarazaex.olc.handler.SettingsManager
import xyz.zarazaex.olc.handler.V2RayNativeManager
import xyz.zarazaex.olc.handler.V2rayConfigManager
@@ -29,29 +33,37 @@ class RealPingWorkerService(
private val totalCount = AtomicInteger(guids.size)
private val finishedCount = AtomicInteger(0)
private val pendingResults = ArrayList<PingResultItem>()
private val pendingLock = Any()
private val delayTestUrl = SettingsManager.getDelayTestUrl()
companion object {
private const val RESULT_BATCH_SIZE = 32
private const val FLUSH_INTERVAL_MS = 1000L
}
data class PingItem(val guid: String, val config: String)
fun start() {
scope.launch(Dispatchers.IO) {
while (isActive) {
delay(FLUSH_INTERVAL_MS)
flushPendingResults()
}
}
scope.launch(Dispatchers.IO) {
try {
// Prepare configurations for batch test and shuffle for better async feel
val items =
guids.shuffled().mapNotNull { guid ->
try {
val configResult =
V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
if (configResult.status) {
PingItem(guid, configResult.content)
} else {
// Notify failure immediately for invalid configs
reportResult(guid, -1L)
null
}
} catch (e: Exception) {
android.util.Log.e(AppConfig.TAG, "Failed to prepare config for $guid", e)
val configResult =
V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
if (configResult.status) {
PingItem(guid, configResult.content)
} else {
// Notify failure immediately for invalid configs
reportResult(guid, -1L)
null
}
@@ -73,13 +85,11 @@ class RealPingWorkerService(
)
}
if (job.isActive) {
onFinish("0")
}
flushPendingResults()
onFinish("0")
} catch (e: Exception) {
if (job.isActive) {
onFinish("-1")
}
flushPendingResults()
onFinish("-1")
} finally {
cancel()
}
@@ -87,22 +97,43 @@ class RealPingWorkerService(
}
private fun reportResult(guid: String, delay: Long) {
if (!job.isActive) return
// Launch in scope to unblock Go worker immediately
scope.launch {
val finished = finishedCount.incrementAndGet()
val total = guids.size
// Notify UI about the individual result
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, delay))
// Throttle progress updates: every 10 items or the very last one
if (finished % 10 == 0 || finished == total) {
val left = total - finished
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / $total")
val finished = finishedCount.incrementAndGet()
var readyBatch: PingProgressUpdate? = null
synchronized(pendingLock) {
pendingResults.add(PingResultItem(guid, delay))
if (pendingResults.size >= RESULT_BATCH_SIZE || finished >= totalCount.get()) {
readyBatch = createProgressUpdateLocked(finished)
pendingResults.clear()
}
}
readyBatch?.let(::sendBatchUpdate)
}
private fun flushPendingResults() {
val finished = finishedCount.get()
val update =
synchronized(pendingLock) {
if (pendingResults.isEmpty()) {
null
} else {
createProgressUpdateLocked(finished).also { pendingResults.clear() }
}
}
update?.let(::sendBatchUpdate)
}
private fun createProgressUpdateLocked(finished: Int): PingProgressUpdate {
return PingProgressUpdate(
results = ArrayList(pendingResults),
finished = finished,
total = totalCount.get()
)
}
private fun sendBatchUpdate(update: PingProgressUpdate) {
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_BATCH, update)
val left = (update.total - update.finished).coerceAtLeast(0)
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / ${update.total}")
}
fun cancel() {
@@ -15,8 +15,6 @@ import xyz.zarazaex.olc.databinding.ItemRecyclerFooterBinding
import xyz.zarazaex.olc.databinding.ItemRecyclerMainBinding
import xyz.zarazaex.olc.dto.ProfileItem
import xyz.zarazaex.olc.dto.ServersCache
import xyz.zarazaex.olc.extension.toastError
import xyz.zarazaex.olc.extension.toastSuccess
import xyz.zarazaex.olc.handler.AngConfigManager
import xyz.zarazaex.olc.handler.MmkvManager
import xyz.zarazaex.olc.helper.ItemTouchHelperAdapter
@@ -202,14 +200,6 @@ class MainRecyclerAdapter(
mainViewModel.reloadServerList()
}
holder.itemMainBinding.ivCopy.setOnClickListener {
if (AngConfigManager.share2Clipboard(context, guid) == 0) {
context.toastSuccess(R.string.toast_success)
} else {
context.toastError(R.string.toast_failure)
}
}
holder.itemMainBinding.infoContainer.setOnClickListener {
adapterListener?.onSelectServer(guid)
}
@@ -15,6 +15,7 @@ import xyz.zarazaex.olc.AngApplication
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.R
import xyz.zarazaex.olc.dto.GroupMapItem
import xyz.zarazaex.olc.dto.PingProgressUpdate
import xyz.zarazaex.olc.dto.ServersCache
import xyz.zarazaex.olc.dto.SubscriptionCache
import xyz.zarazaex.olc.dto.SubscriptionUpdateResult
@@ -628,6 +629,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
updateListAction.value = getPosition(resultPair.first)
}
AppConfig.MSG_MEASURE_CONFIG_BATCH -> {
val update = intent.serializable<PingProgressUpdate>("content") ?: return
update.results.forEach { result ->
MmkvManager.encodeServerTestDelayMillis(result.guid, result.delay)
}
updateListAction.value = -1
}
AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> {
val content = intent.getStringExtra("content")
updateTestResultAction.value =
@@ -110,18 +110,6 @@
android:padding="@dimen/padding_spacing_dp8"
android:src="@drawable/ic_star_empty" />
<ImageView
android:id="@+id/iv_copy"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:padding="@dimen/padding_spacing_dp8"
android:src="@drawable/ic_copy"
app:tint="?attr/colorAccent" />
</LinearLayout>
<LinearLayout