Skip to content

Commit a5f89b5

Browse files
committed
Fixed speed test related issues, optimized cache management, and enhanced network interface monitoring.
1 parent 55d255e commit a5f89b5

9 files changed

Lines changed: 107 additions & 45 deletions

File tree

REPO_SCHEMA.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Android 端的代码主要分为两个核心包:
3131
- `ProfileManager.kt` / `GroupManager.kt`: 配置和分组管理器。
3232
- [`io/nekohasekai/sagernet/fmt/`](app/src/main/java/io/nekohasekai/sagernet/fmt): 各种代理协议的配置格式化与解析。`ConfigBuilder.kt` 生成 sing-box 配置,已对齐官方 v1.13 schema:入站 sniff/解析目标地址迁移为路由规则动作(`sniff`/`resolve`,置于规则最前);tun 地址用合并字段 `address``inet4_address`/`endpoint_independent_nat` 等 legacy 字段已弃用);「双网络加速」设置(`dualNetworkAcceleration`,原误名 `concurrent_dial`——"并发拨号"实为 singbox-p 社区版概念,与官核无关)映射为 `default_network_strategy: "hybrid"`;DNS 地址归一化:dns-direct/订阅 resolver 的 `hosts` 视为 `local`(本机直解,语义正确),远程 DNS 的 `hosts`/`local`/`localhost`/`fakeip` 占位符一律回退为 `https://8.8.8.8/dns-query` 并 Toast 提示用户修改设置(本机直解用作远程即 DNS 泄露),且 dns-remote 显式 `detour`=当前节点(对齐 Throne 桌面端 `detour:"proxy"`)。
3333
- 支持 Shadowsocks, VMess, Trojan, Hysteria, Juicity, Naive, WireGuard 等协议的配置解析与转换。
34-
- `forTest`(URL 测速)配置的 cache 隔离:官方内核在 `PlatformLogWriter != nil` 时无条件创建 CacheFile(bbolt)与 ClashServer,无显式 `path` 时所有实例共用工作目录(`no_backup`)下的 `cache.db`。主进程批量测速并发创建大量 `TestInstance` 曾共享该文件,bbolt freelist 被写坏后每次 `box.Start` 的清理 batch 在 bbolt 定时器 goroutine 中 `page already freed` panic(异步 goroutine 中无法 recover)→ 主进程 SIGABRT 闪退。现每个测试实例分配独立临时文件 `urltest_<profileId>_<nanoTime>.db`,测完由 `TestInstance.doTest` 删除[`SagerNet.onCreate`](app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt)(main/bg 进程)启动时清扫存量共享 `cache.db` 与残留 `urltest_*.db` 实现老用户自愈。
34+
- `forTest`(URL 测速)配置不生成 `experimental` 块:官方内核在 `PlatformLogWriter != nil` 时无条件创建 CacheFile(bbolt)与 ClashServer,无显式 `path` 时所有实例共用工作目录(`no_backup`)下的 `cache.db`。主进程批量测速并发创建大量 `TestInstance` 曾共享该文件,bbolt freelist 被写坏后每次 `box.Start` 的清理 batch 在 bbolt 定时器 goroutine 中 `page already freed` panic(异步 goroutine 中无法 recover)→ 主进程 SIGABRT 闪退。最终修复在 Go 侧:测速实例走 [`NewTestSingBoxInstance`](libcore/box.go)(不注册 PlatformLogWriter → 官方 `needCacheFile`/`needClashAPI` 均不触发),测速完全不产生 cache.db;[`SagerNet.onCreate`](app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt)(main/bg 进程)启动时仍清扫存量共享 `cache.db` 与历史残留 `urltest_*.db` 实现老用户自愈。另:测速拨号依赖平台接口监视器,`NativeInterface.startDefaultInterfaceMonitor` 必须同步注册(异步曾致首拨竞态秒报 `no available network interface`),`SagerNet` 两个进程常驻 `DefaultNetworkListener` 预热缓存
3535
- `SingBoxOutboundParser.kt`: 将 sing-box 配置中的单个 outbound JSON 还原为原生协议 Bean(支持 shadowsocks/vmess/vless/trojan/hysteria/hysteria2/tuic/socks/http/wireguard/anytls,含 TLS/transport/multiplex 子块解析)。用于订阅返回完整 sing-box 配置(含 `outbounds`)的场景:`RawUpdater.parseJSON``outbounds` 分支对每个 outbound 优先调用 `parseSingBoxOutbound()` 还原原生节点,不支持的类型或解析失败时回退为 `ConfigBean`(自定义 JSON);`dns`/`block`/`direct`/`selector`/`urltest` 类型的 outbound 始终跳过。
3636
- [`io/nekohasekai/sagernet/ui/`](app/src/main/java/io/nekohasekai/sagernet/ui): 各种 Activity 和 Fragment 界面。
3737
- `MainActivity.kt`: 应用主界面。预览版启动时弹出提示对话框(标题为 `BuildConfig.PRE_VERSION_NAME`),提供「不再显示」按钮(`preview_hint_dont_show_again`):点击后把当前版本号写入 `DataStore.previewHintDismissedVersion``Key.PREVIEW_HINT_DISMISSED_VERSION`),之后仅对已忽略的版本不再弹窗,发布新预览版时会重新提示。

ROO_KERNEL_TODO.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@
3333
- DNS 地址 `hosts`(旧 fork=系统解析器):官方 legacy 升级把裸 `hosts` 静默误判为 UDP 服务器域名(远程 DNS 全灭且不报错)→ `normalizeDnsAddress()` 归一化为 `local`(仅 dns-direct/订阅 resolver 等本机直解场景)
3434
- 远程 DNS 防泄露(2026-08-01 二轮修复):首版把远程 `hosts` 也归一化为 `local`,实测 DNS 泄露(官方内核 `local` 在 Android 走平台接口经物理网卡直连系统 DNS,与 fork `hosts` "节点代访问"语义不对齐)→ 远程 DNS 遇 `hosts`/`local`/`localhost`/`fakeip` 占位符一律回退 `https://8.8.8.8/dns-query` + Toast 提示(`normalizeRemoteDnsAddress()`);dns-remote 补 `detour = mainProxyTag`(当前节点,对齐 Throne 桌面端 `detour:"proxy"`/husi `detour=mainTag`;legacy 无 detour 时虽兜底走默认出站,显式指定才无歧义)。注:官方 legacy DNS 格式 1.14 将移除,且新结构化格式下无 detour 的 DNS server 默认**直连**(不再兜底默认出站),届时迁移必须保留显式 detour
3535
- tun 入站 `inet4_address`/`inet6_address`:字段虽仍在结构体(可解析),但构造函数硬报错(1.12 已移除,`initialize inbound[0]` 失败)→ 迁移为合并字段 `address``endpoint_independent_nat` 同步停止发射(官方已移除语义,当前无硬检查但迟早硬化)。Kotlin 侧 `startVpn` 不解析 tun JSON(地址/路由/MTU 用 DataStore),零影响
36-
- **URL 测速闪退修复**(2026-08-01 二轮):官方 [`box.go`](C:/repos/_inspect/sing-box/box.go) 在 `PlatformLogWriter != nil` 时无条件 `needCacheFile`/`needClashAPI`(libbox 假设单进程单实例),Throne 的 `NewSingBoxInstance` 恒传 `boxPlatformLogWriter` → 主进程批量测速的并发 `TestInstance` 全部共享 `no_backup/cache.db`(bbolt)。文件 freelist 被并发写坏后能正常打开、提交时才 `page already freed` panic,且 panic 在 bbolt `time.AfterFunc` 的 batch 提交 goroutine 中([`cachefile.batch()`](C:/repos/_inspect/sing-box/experimental/cachefile/cache.go) 的 recover 只兜同步段)→ 主进程 SIGABRT 必闪退。修复(Kotlin-only,不动官方内核):`ConfigBuilder` 的 `forTest` 分支为每个测试实例发独立临时 cache 文件(`urltest_<id>_<nanoTime>.db`)相互隔离,`TestInstance.doTest` 测完删除;`SagerNet.onCreate` 启动时清扫存量损坏的共享 `cache.db` 与残留临时文件(老用户自愈)。零配置 ClashServer 已确认无害(`ExternalController` 为空不监听,仅只读 `LoadMode`)
36+
- **URL 测速闪退修复**(2026-08-01 二轮兜底、三轮 Go 侧根治):官方 [`box.go`](C:/repos/_inspect/sing-box/box.go) 在 `PlatformLogWriter != nil` 时无条件 `needCacheFile`/`needClashAPI`(libbox 假设单进程单实例),Throne 的 `NewSingBoxInstance` 恒传 `boxPlatformLogWriter` → 主进程批量测速的并发 `TestInstance` 全部共享 `no_backup/cache.db`(bbolt)。文件 freelist 被并发写坏后能正常打开、提交时才 `page already freed` panic,且 panic 在 bbolt `time.AfterFunc` 的 batch 提交 goroutine 中([`cachefile.batch()`](C:/repos/_inspect/sing-box/experimental/cachefile/cache.go) 的 recover 只兜同步段)→ 主进程 SIGABRT 必闪退。二轮曾用 Kotlin-only 独立临时文件(`urltest_*.db`)隔离兜底;三轮根治:[`libcore/box.go`](libcore/box.go) 拆出 `newSingBoxInstance(config, transport, platformLog)`,新增 `NewTestSingBoxInstance` 不注册 PlatformLogWriter → CacheFile/ClashServer 均不创建,测速零 cache 文件(`ConfigBuilder` forTest 分支回退为不生成 experimental,`TestInstance` 改用新构造函数);`SagerNet.onCreate` 清扫保留(老用户自愈)。零配置 ClashServer 无害(`ExternalController` 为空不监听)
37+
- **测速结果失真修复**(2026-08-01 三轮,真机日志实锤):症状=批量测速飞快结束、大面积"超时"、连当前连接中的节点也超时。根因一:**接口监视器注册竞态**——`NativeInterface.startDefaultInterfaceMonitor` 原用 `runOnDefaultDispatcher` 异步注册,测试盒 `box.Start()` 后零等待首拨,`DefaultInterface()==nil` → 秒报 `no available network interface`(日志实锤;另有变体:`UpdateDefaultInterface` 里 `NewDefaultInterfaceFinder().ByIndex` 瞬时失败 `find updated interface: wlan0 ... no such network interface`,更新被永久丢弃)→ 修复:注册改 `runBlocking` 同步(actor 是 Unconfined,缓存命中时首回调在注册返回前完成);`SagerNet` 主进程原有常驻监听预热、:bg 进程补齐同款;ByIndex 失败改后台重试 5 次。根因二:**protect 串行回环**——`protect.go` accept 循环同步处理每个 fd,JNI protect 排队时主进程 `sendFdToProtect` 100ms 超时失败 → fd 未 protect,测速流量回环进 tun(日志实锤 `tun-in` 收到测试包,当前节点形成"服务器自连"死结)→ `go handleProtectConn` 并发化 + recover 兜底。诊断日志:`ConfigurationFragment.urlTest` 失败分支 `Logs.w` 记录原始错误(真机验证通过后可移除)
3738
- **接口监视器补齐**(`no available network interface` 全部拨号失败,两半缺一不可):① 官方 v1.13 只要注册了 PlatformInterface 就强制走平台 `CreateDefaultInterfaceMonitor`(`route/network.go`),旧 `interfaceMonitorStub` 空实现 `DefaultInterface()=nil` → [`interface_monitor.go`](libcore/interface_monitor.go) 重写为完整实现(参考 husi `tun.go`,含 `InterfaceUpdateListener` JNI 回调接口),`BoxPlatformInterface` 新增 `StartDefaultInterfaceMonitor`/`CloseDefaultInterfaceMonitor`;Kotlin 侧 [`NativeInterface.kt`](app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt) 复用现成 `DefaultNetworkListener`(`registerBestMatchingNetworkCallback` 天然避开 VPN 接口,报告物理默认网络)。② **平台接口枚举同为硬性要求**:注册 PlatformInterface 后拨号器恒走并行接口选择(`common/dialer` strategy 默认非 nil),而 `NetworkManager.UpdateInterfaces()` 只在 `UsePlatformNetworkInterfaces()=true` 的平台分支缓存接口列表——为 false 时列表恒空,`selectInterfaces` 选不出接口报同款错误 → `UsePlatformNetworkInterfaces()` 改 true,`NetworkInterfaces()` 经 JNI `GetInterfaces()` 实现(husi `platform_box.go` 转换逻辑);新增 `iterator.go`(StringIterator)、`network_interface.go`(NetworkInterface/迭代器/类型常量)、`link_flags_unix.go`(IFF_* → net.Flags);Kotlin 侧 `getInterfaces()` 枚举 allNetworks+LinkProperties+NetworkCapabilities(husi 同款),`interfaceMonitor.UpdateDefaultInterface` 回调时先 `networkManager.UpdateInterfaces()` 刷新缓存(Initialize 晚于 CreateDefaultInterfaceMonitor 调用,wrapper 指针延迟访问)。T1-6 的 interfaceMonitor 部分就此完成
3839

3940
**已知降级项**(debug 日志兜底,待用户反馈具体案例):SSR/Snell 节点(官方无)、`ResetAllConnections`、Clash API(yacd)切换 selector 不触发 `selector_OnProxySelected` 回调、**WireGuard 节点**(官方 1.13 仅支持 endpoint 形态,而 [`WireGuardFmt.kt`](app/src/main/java/io/nekohasekai/sagernet/fmt/wireguard/WireGuardFmt.kt) 仍生成 outbound 配置,`box.New` 会报 stub 错误;需做配置生成的 outbound→endpoint 迁移:`Outbound_WireGuardOptions``Endpoint_WireGuardOptions` 并写入 `endpoints` 数组,字段结构有差异需对照 `option/wireguard.go`)。

app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ class SagerNet : Application(),
6363
// 并发 TestInstance 曾共享该文件导致 bbolt freelist 损坏("page already freed"
6464
// panic 在异步 batch goroutine 中无法 recover → SIGABRT 闪退),且损坏文件
6565
// 能正常打开、提交时才崩,官方 Open 阶段的校验发现不了。
66-
// 现测试实例已改用独立临时文件(ConfigBuilder forTest 分支),这里在进程启动、
67-
// 尚无 box 打开时清扫存量损坏文件及残留临时文件,实现老用户自愈。
66+
// 现测试实例已从 Go 侧彻底不创建 cache(libcore NewTestSingBoxInstance),
67+
// 这里在进程启动、尚无 box 打开时清扫存量损坏文件及历史残留,实现老用户自愈。
6868
runCatching {
6969
File(noBackupFilesDir, "cache.db").delete()
7070
noBackupFilesDir.listFiles { file -> file.name.startsWith("urltest_") }
@@ -81,6 +81,18 @@ class SagerNet : Application(),
8181
nativeInterface, nativeInterface, LocalResolverImpl
8282
)
8383

84+
if (isBgProcess) {
85+
// 常驻注册默认网络监听:预热 DefaultNetworkListener 的 network 缓存,
86+
// 使本进程 box 的接口监视器 Start 即同步拿到默认接口
87+
// (主进程已在下方 isMainProcess 分支做同样的事;
88+
// 竞态背景见 NativeInterface.startDefaultInterfaceMonitor 批注)。
89+
runOnDefaultDispatcher {
90+
DefaultNetworkListener.start(this@SagerNet) {
91+
underlyingNetwork = it
92+
}
93+
}
94+
}
95+
8496
// fix multi process issue in Android 9+
8597
JavaUtil.handleWebviewDir(this)
8698

app/src/main/java/io/nekohasekai/sagernet/bg/proto/TestInstance.kt

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package io.nekohasekai.sagernet.bg.proto
22

33
import io.nekohasekai.sagernet.BuildConfig
4-
import io.nekohasekai.sagernet.SagerNet
54
import io.nekohasekai.sagernet.bg.GuardedProcessPool
65
import io.nekohasekai.sagernet.database.ProxyEntity
76
import io.nekohasekai.sagernet.fmt.buildConfig
@@ -12,7 +11,6 @@ import io.nekohasekai.sagernet.ktx.tryResumeWithException
1211
import kotlinx.coroutines.delay
1312
import libcore.Libcore
1413
import moe.matsuri.nb4a.net.LocalResolverImpl
15-
import java.io.File
1614
import kotlin.coroutines.suspendCoroutine
1715

1816
class TestInstance(profile: ProxyEntity, val link: String, private val timeout: Int) :
@@ -38,17 +36,6 @@ class TestInstance(profile: ProxyEntity, val link: String, private val timeout:
3836
c.tryResumeWithException(e)
3937
}
4038
}
41-
// 删除本测试实例的独立临时 cache 文件(见 ConfigBuilder forTest 分支),
42-
// 避免批量测速在 no_backup 下积累大量 urltest_*.db。
43-
// (父类 lateinit config 不能在此用 ::config.isInitialized 判断,
44-
// 未初始化时访问抛 UninitializedPropertyAccessException,由 runCatching 兜底)
45-
runCatching {
46-
config.testCacheFile?.let { name ->
47-
runCatching {
48-
File(SagerNet.application.noBackupFilesDir, name).delete()
49-
}
50-
}
51-
}
5239
}
5340
}
5441
}
@@ -60,7 +47,9 @@ class TestInstance(profile: ProxyEntity, val link: String, private val timeout:
6047
override suspend fun loadConfig() {
6148
// don't call destroyAllJsi here
6249
if (BuildConfig.DEBUG) Logs.d(config.config)
63-
box = Libcore.newSingBoxInstance(config.config, LocalResolverImpl)
50+
// 测速实例用 NewTestSingBoxInstance:不注册 PlatformLogWriter,
51+
// 官方内核不再强制创建 CacheFile/ClashServer(见 libcore/box.go 批注)。
52+
box = Libcore.newTestSingBoxInstance(config.config, LocalResolverImpl)
6453
}
6554

6655
}

app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,6 @@ class ConfigBuildResult(
6767
val selectorGroupId: Long,
6868
) {
6969
data class IndexEntity(var chain: LinkedHashMap<Int, ProxyEntity>)
70-
71-
// forTest 配置为该测试实例生成的独立临时 cache 文件名(相对 no_backup 工作目录),
72-
// 测速完成后由 TestInstance 删除;非测速配置恒为 null。
73-
var testCacheFile: String? = null
7470
}
7571

7672
private fun sanitizeDnsEntry(value: String): String {
@@ -234,25 +230,12 @@ fun buildConfig(
234230
}
235231
}
236232

237-
var testCacheFile: String? = null
238233
return MyOptions().apply {
239-
if (forTest) {
240-
// 官方内核在 PlatformLogWriter != nil 时无条件创建 CacheFile 与 ClashServer
241-
// (sing-box box.go 的 needCacheFile/needClashAPI),测试实例无法关闭。
242-
// 不显式给 path 时所有实例共用工作目录(no_backup)下的 cache.db:主进程批量
243-
// 测速会并发创建大量 TestInstance 共享同一 bbolt 文件,freelist 被写坏后,
244-
// 每次 box.Start 的清理 batch 在 bbolt 定时器 goroutine 中 panic(该 panic 在
245-
// 异步 goroutine 中,cachefile 的 recover 兜不住)→ 主进程 SIGABRT 闪退。
246-
// 故为每个测试实例分配独立临时文件相互隔离,测完由 TestInstance 删除。
247-
val testCacheFileName = "urltest_" + proxy.id + "_" + System.nanoTime() + ".db"
248-
testCacheFile = testCacheFileName
249-
experimental = ExperimentalOptions().apply {
250-
cache_file = CacheFile().apply {
251-
enabled = true
252-
path = testCacheFileName
253-
}
254-
}
255-
} else {
234+
// forTest 不配 experimental:Go 侧 NewTestSingBoxInstance 不注册
235+
// PlatformLogWriter,官方内核据此不再强制创建 CacheFile/ClashServer
236+
// (官方 box.go 的 needCacheFile/needClashAPI 分支),测速完全不产生
237+
// cache.db——曾因此引发主进程并发共享 bbolt 文件损坏闪退,现从根上移除。
238+
if (!forTest) {
256239
experimental = ExperimentalOptions().apply {
257240
cache_file = CacheFile().apply {
258241
enabled = true
@@ -1107,7 +1090,7 @@ fun buildConfig(
11071090
trafficMap,
11081091
tagMap,
11091092
if (buildSelector) group.id else -1L
1110-
).also { it.testCacheFile = testCacheFile }
1093+
)
11111094
}
11121095

11131096
}

app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import io.nekohasekai.sagernet.ktx.app
1818
import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher
1919
import io.nekohasekai.sagernet.utils.DefaultNetworkListener
2020
import io.nekohasekai.sagernet.utils.PackageCache
21+
import kotlinx.coroutines.runBlocking
2122
import libcore.BoxPlatformInterface
2223
import libcore.InterfaceUpdateListener
2324
import libcore.Libcore
@@ -92,7 +93,13 @@ class NativeInterface : BoxPlatformInterface, NB4AInterface {
9293

9394
override fun startDefaultInterfaceMonitor(listener: InterfaceUpdateListener?) {
9495
if (listener == null) return
95-
runOnDefaultDispatcher {
96+
// 必须同步注册:官方内核拨号时 DefaultInterface()==nil 会秒报
97+
// "no available network interface"(见 libcore/interface_monitor.go 批注)。
98+
// 原先 runOnDefaultDispatcher 异步注册,测试盒 box.Start() 后立刻拨号,
99+
// 首拨几乎必然抢在首次回调之前 → 批量测速大面积"超时"。
100+
// DefaultNetworkListener 的 actor 是 Dispatchers.Unconfined,send 内联处理,
101+
// 缓存命中时首次回调在此调用返回前即完成(调用的 Go 线程短暂阻塞,可接受)。
102+
runBlocking {
96103
DefaultNetworkListener.start(listener) { network ->
97104
checkDefaultInterfaceUpdate(listener, network)
98105
}
@@ -101,7 +108,8 @@ class NativeInterface : BoxPlatformInterface, NB4AInterface {
101108

102109
override fun closeDefaultInterfaceMonitor(listener: InterfaceUpdateListener?) {
103110
if (listener == null) return
104-
runOnDefaultDispatcher {
111+
// 与 start 对称同步化,避免 box.Close 后监听者残留/时序错乱。
112+
runBlocking {
105113
DefaultNetworkListener.stop(listener)
106114
}
107115
}

0 commit comments

Comments
 (0)