diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt index d3372a6..453e753 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt @@ -10,14 +10,12 @@ import top.yeij.cyrene.data.api.AdminApi import top.yeij.cyrene.data.api.ApiClientFactory import top.yeij.cyrene.data.api.ChatApi import top.yeij.cyrene.data.api.DocumentsApi -import top.yeij.cyrene.data.api.HealthApi import top.yeij.cyrene.data.api.MetaApi import top.yeij.cyrene.data.api.PersonaApi import top.yeij.cyrene.data.api.SessionsApi import top.yeij.cyrene.data.api.UploadApi import top.yeij.cyrene.data.repository.AdminRepository import top.yeij.cyrene.data.repository.ChatRepository -import top.yeij.cyrene.data.repository.ConnectivityRepository import top.yeij.cyrene.data.repository.DocumentRepository import top.yeij.cyrene.data.repository.MessageRepository import top.yeij.cyrene.data.repository.MetaRepository @@ -204,28 +202,6 @@ class AppContainer(context: Context) { val personaRepository: PersonaRepository get() = PersonaRepository(personaApi) - private var cachedHealthApi: HealthApi? = null - private var cachedHealthBaseUrl: String? = null - - /** - * 连通性探测客户端(`GET /api/v1/health`,W41)。与其余端点**同源**(同一个 baseUrl), - * 但它刻意**不注入令牌、不参与 401 续期**,并且整通调用有短上限 - * ([ApiClientFactory.createProbe],理由见那里的 KDoc)——探测要回答的是「后端在不在」, - * 带着过期令牌去问只会把「后端好好的」读成不通。 - */ - val healthApi: HealthApi - get() { - val baseUrl = authManager.currentBaseUrl - val cached = cachedHealthApi - if (cached != null && cachedHealthBaseUrl == baseUrl) return cached - val created = ApiClientFactory.createProbe(baseUrl, HealthApi::class.java) - cachedHealthBaseUrl = baseUrl - cachedHealthApi = created - return created - } - - val connectivityRepository: ConnectivityRepository get() = ConnectivityRepository(healthApi) - /** * 进程内**唯一**的实时通道连接(`GET /ws/chat`):所有需要收下行帧的 ViewModel 共用这一条。 * diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt index 27d007a..e153a70 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/ApiClientFactory.kt @@ -13,26 +13,33 @@ import okhttp3.Response import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory -import java.time.Duration import java.util.concurrent.TimeUnit /** - * 连通性探测的**整通上限**(毫秒):`GET /api/v1/health` 从发出到拿到回执,连接、写、读全算在内 - * (OkHttp 的 `callTimeout` 就是这么算的)。 + * **连接超时**(毫秒):解析 DNS + 建立 TCP 连接(以及 TLS 握手)这一段的预算,**全端共用这一个值**。 * - * 取值 2.5 秒。它直接**加在用户的发送动作上**——用户按了发送键,这条消息要么立刻上路,要么进 - * 补发队列,中间那一段探测是他等着的空白。健康端点是纯内存回一个 `{"status":"ok"}` - * (`Cyrene_v2/internal/services/gateway/rest.go:219`,且挂在**不鉴权**的公开路由里,见 - * `Cyrene_v2/internal/services/gateway/routes.go:51`),正常网络下毫秒级就该回来;2 秒量级对局域网 - * 与移动网都绰绰有余,3 秒是「人开始觉得卡」之前的上限。 + * ## 为什么从 10 秒缩到 2.5 秒(W45) + * 它就是「**完全无网时快速进队列**」这条性质的唯一依据。W41 把这条性质寄托在一个 2.5 秒的探针上 + * (W41 那个探针 client 的 `callTimeout`),而那个探针**从不复用连接池**、每次都要现做一次冷握手 + * ——于是真机上出现了「探了,但请求一个字节都没写出去」,而它与「压根没探」在外部完全同形,推断 + * 不出是哪一档。W45 删掉探测、改成**直接试 REST** 之后,这条性质改由**连接超时**承担:连不上就在 2.5 秒 + * 内失败,消息进队列(内容一个字节都不丢,重发仍是同一个 `clientMsgId`)。 * - * 超过它一律按**不通**处理([top.yeij.cyrene.ui.chat.reachabilityOf] 的 fail-closed): - * **绝不许**把用户的发送挂在一个等不到头的探测上。 + * 取值 2.5 秒:正常网络下一次 TCP 握手是毫秒到几十毫秒量级(局域网、ZeroTier / Tailscale 也远达 + * 不到秒级),2.5 秒是「人开始觉得卡」之前的量级,也与 W41 那 2.5 秒的探针预算**对齐**——换成直接 + * 试 REST 之后,「无网时用户最多等多久」因此没有变长。**绝不许**再回到 10 秒:那会让每一条离线 + * 消息的用户都白等十倍的时间,换来的只是「极端慢网下多一次成功的机会」。 * - * 它与共用客户端的 10s 连接 / 60s 读超时**刻意不同**:那两条是给「真请求」定的(长回合、上传), - * 探测只回答一个 yes/no,用不上那么长的耐心。 + * ## 它不管什么 + * 「连上了但服务端一直不回话」(半死的代理、接住连接却不回应)不是它的事——那是读 / 写超时 + * ([READ_TIMEOUT_SECONDS])与回落那一次投递自己的整通上限 + * ([top.yeij.cyrene.ui.chat.REST_FALLBACK_TIMEOUT_MILLIS])的事。两个闸各管一段,合起来才是 + * 「没网时用户最多等多久」。 */ -const val PROBE_TIMEOUT_MILLIS = 2_500L +const val CONNECT_TIMEOUT_MILLIS = 2_500L + +/** 读 / 写超时(秒):真请求(长文本、内联图片、文档字节)的耐心,与 [CONNECT_TIMEOUT_MILLIS] 分开。 */ +private const val READ_TIMEOUT_SECONDS = 60L object ApiClientFactory { @@ -49,7 +56,20 @@ object ApiClientFactory { unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy, ): CyreneApi = create(baseUrl, CyreneApi::class.java, tokenProvider, unauthorizedPolicy) - /** 通用入口:按需构建任意 Retrofit service。 */ + /** + * 通用入口:按需构建任意 Retrofit service。 + * + * 超时**只有两个值**,都由文件顶上的常量解释:连接 [CONNECT_TIMEOUT_MILLIS](短,保证「完全 + * 无网时快速失败」)、读 / 写 [READ_TIMEOUT_SECONDS](长,真请求的耐心)。**刻意不设 + * `callTimeout`**:那会把文档字节下载与图片上传一并框住,而它们本来就是长请求;哪一次投递需要 + * 整通上限,就写在哪一次投递的调用点上(例:回落那一次的 + * [top.yeij.cyrene.ui.chat.REST_FALLBACK_TIMEOUT_MILLIS])。 + * + * DEBUG 下挂 BASIC 日志(`logcat -s OkHttp`):`-->` 打在 `chain.proceed()` **之前**,所以 + * 「这条请求有没有发出去」与「死在连接还是死在回执」当场可分——回落那条路下一轮要靠它取证。 + * 不动 `Authorization`(BASIC 根本不打印任何头),级别**绝不许**再往上调:`/auth/refresh` 与 + * `/auth/logout` 把 refresh token 放在请求体里。 + */ fun create( baseUrl: String, service: Class, @@ -57,9 +77,11 @@ object ApiClientFactory { unauthorizedPolicy: UnauthorizedPolicy = NoUnauthorizedPolicy, ): T { val client = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(60, TimeUnit.SECONDS) + // 连接这一段刻意短(理由见 [CONNECT_TIMEOUT_MILLIS]):它是「无网时消息快速进队列」的 + // 唯一依据,所以它**不含**任何整通上限的意味。 + .connectTimeout(CONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) .addInterceptor(AuthInterceptor(tokenProvider, unauthorizedPolicy)) .apply { if (BuildConfig.DEBUG) { @@ -83,58 +105,6 @@ object ApiClientFactory { .create(service) } - /** - * 连通性探测专用客户端(W41):整通调用一个**短上限**([PROBE_TIMEOUT_MILLIS]), - * 不注入令牌、不参与 401 续期。 - * - * 与上面那个通用 [create] 分开,理由三条: - * 1. **超时是这条路的语义本身**。共用客户端的 10s 连接 / 60s 读超时是给真请求定的,探测等不了 - * 那么久——判据见 [PROBE_TIMEOUT_MILLIS]。`callTimeout` 把「解析 DNS + 连接 + 写 + 读」 - * 整通一起框住,只会更严不会更松:**探测永远不会比这个上限更慢**。 - * - * ⚠️ 这条「整通」也**含连接**,而本客户端从不复用连接池(理由 3)⇒ 每一次探测都要现做一次 - * TCP 握手,而这次握手的时间预算只有 [PROBE_TIMEOUT_MILLIS],**比共用客户端给的 10s 连接 - * 预算紧 4 倍**。连接比这个上限慢时(慢网、经 `adb reverse` 的模拟器、半死的代理), - * OkHttp **一个字节都还没写出去**就超时了 ⇒ 服务端/中间设备**看不到任何请求**,只看到一个 - * 连上又不说话的连接。诊断真机现象时必须先分清这一档(判据与步骤见 - * [top.yeij.cyrene.data.repository.ConnectivityRepository.probe])。 - * 2. **不带令牌**。`GET /api/v1/health` 是公开路由(不鉴权),探测要回答的是「后端在不在」, - * 而不是「我这个令牌还有效吗」——带着一个过期令牌去问,只会把「后端好好的」读成不通。 - * 不挂 [AuthInterceptor] 同时也就没有「401 ⇒ 续期 ⇒ 再试」那串额外往返。 - * 3. **不共享连接池**:探测的成败不该被真请求的连接状况带着走(半死的 keep-alive 连接恰好是 - * 「要探测一下」的典型场景),自己一个池更干净。代价是一个只在探测时被用到的客户端。 - * - * ## 为什么 DEBUG 下要给它挂上日志(W44) - * 这条路的失败**默认是无声的**:它的结论被折进一个 `Reachability.Unreachable`, - * 最后只表现为「消息进了队列」。W41 的真机回归就栽在这里——代理日志里一条 `/api/v1/health` - * 都没有,而「压根没探」与「探了但死在连接阶段(请求一个字节都没写出去)」在外部**完全同形**, - * 于是那次回归无法定论。BASIC 记的是**请求行 + 响应行 + 失败原因**:它落在连接**之前** - * (`-->` 先于 `chain.proceed()`),所以「有没有探」与「死在连接还是死在回执」当场可分。 - * 它在这条路上不泄露任何东西:探测不带令牌、也没有请求体(与 [create] 挂日志时对凭据的顾虑 - * 无关,这里没有凭据要涂抹)。 - */ - fun createProbe(baseUrl: String, service: Class): T { - val client = OkHttpClient.Builder() - .callTimeout(Duration.ofMillis(PROBE_TIMEOUT_MILLIS)) - .apply { - if (BuildConfig.DEBUG) { - addInterceptor( - HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BASIC - }, - ) - } - } - .build() - - return Retrofit.Builder() - .baseUrl(normalizeBaseUrl(baseUrl)) - .client(client) - .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) - .build() - .create(service) - } - /** Retrofit 要求 baseUrl 以 `/` 结尾;用户没写 scheme 时补 `http://`。 */ fun normalizeBaseUrl(raw: String): String { var url = raw.trim() diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/HealthApi.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/HealthApi.kt deleted file mode 100644 index bec5e5c..0000000 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/HealthApi.kt +++ /dev/null @@ -1,22 +0,0 @@ -package top.yeij.cyrene.data.api - -import top.yeij.cyrene.data.api.dto.HealthDto -import retrofit2.Response -import retrofit2.http.GET - -/** - * 存活探针(`GET /api/v1/health`,api-client §1)。**不鉴权**、也没有任何重活: - * 后端就是回一个 `{"data":{"status":"ok"}}`(`Cyrene_v2/internal/services/gateway/rest.go:219`, - * 路由挂在公开那一组,见 `Cyrene_v2/internal/services/gateway/routes.go:51`)。 - * - * 本端只用它回答一个问题:「**这一刻这个 baseUrl 背后的后端在不在**」。答案与业务无关,所以这个 - * 接口**不注入 token、不参与 401 续期**(见 [ApiClientFactory.createProbe]),也刻意不与任何别的 - * 接口合并:它的调用代价与语义都和那些「真请求」不同(短超时、失败即不通)。 - * - * 按本工程惯例单独成接口。 - */ -interface HealthApi { - - @GET("api/v1/health") - suspend fun health(): Response> -} diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/HealthDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/HealthDto.kt deleted file mode 100644 index 95334b7..0000000 --- a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/HealthDto.kt +++ /dev/null @@ -1,24 +0,0 @@ -package top.yeij.cyrene.data.api.dto - -import kotlinx.serialization.Serializable - -/** - * `GET /api/v1/health` 的成功载荷(存活探针)。 - * - * 线上形状(`Cyrene_v2/internal/services/gateway/rest.go:219` 的 `handleHealth`): - * ``` - * 200 {"data":{"status":"ok"}} - * ``` - * - * 只有 `status` 一个键,且它**不参与本端判定**:探测要回答的是「这条路通不通」,`200` 本身就是 - * 答案(见 `ConnectivityRepository.probe`)。字段带默认值是为了「后端将来把 body 改胖 / 改瘦」 - * 不会让一次成功的探测变成解析失败——那是把「后端好好的」读成「不通」,方向恰好反了(会把一条 - * 本可以发出去的消息压进队列)。 - * - * 该端点**不鉴权**(`routes.go:51` 把它挂在 `registerPublicRoutes` 里),也没有任何重活: - * 它就是 `writeJSON(w, 200, {"status":"ok"})`。 - */ -@Serializable -data class HealthDto( - val status: String = "", -) diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/ConnectivityRepository.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/ConnectivityRepository.kt deleted file mode 100644 index 0fb8501..0000000 --- a/app/src/main/kotlin/top/yeij/cyrene/data/repository/ConnectivityRepository.kt +++ /dev/null @@ -1,80 +0,0 @@ -package top.yeij.cyrene.data.repository - -import top.yeij.cyrene.data.api.HealthApi -import top.yeij.cyrene.data.api.apiCall -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** - * 连通性探测(W41):问一次后端「在不在」,只在**发送这条路上用得着**。 - * - * ## 它回答什么、不回答什么 - * 回答的是「**这一刻**这个 baseUrl 背后的后端在不在」——一个瞬时的、与业务无关的 yes/no。 - * 不回答「我能不能发这条消息」(那是编排器的事)、也不回答「我的令牌还有效吗」(这个端点不鉴权, - * 专门这样选:带着过期令牌去问会把「后端好好的」读成不通)。 - * - * ## 为什么用 `apiCall` 而不是自己判状态码 - * 探测只需要两样东西:**拿到 2xx 且回执能解析** = 通,其余 = 不通。[apiCall] 的口径正好是这两样 - * (成功但 `data` 为空判 Parse、非 2xx 按 [top.yeij.cyrene.data.api.ApiErrorMapper] 归类、网络异常 - * 归 [top.yeij.cyrene.core.error.CyreneError.Local]),与别的仓库**同一份**错误映射,不另造一套。 - * - * 返回 [Result] 而不是直接给个布尔:`false` 与「失败了 / 超时了」在这一层是两件事(一个说后端 - * 回了非 2xx,一个说连回话都没有),要不要都归成一档由判据决定 - * ([top.yeij.cyrene.ui.chat.reachabilityOf]:都归「不通」,fail-closed)。 - * - * ## 超时是客户端的属性,不是这里的 - * 上限由 [top.yeij.cyrene.data.api.ApiClientFactory.createProbe] 的 `callTimeout` 兜住 - * ([top.yeij.cyrene.data.api.PROBE_TIMEOUT_MILLIS]),所以这里**不需要**再包一层 `withTimeout`: - * 一道闸就够,两道只会让「超时到底是谁判的」变得含糊。 - * - * ## 不缓存 - * 没有缓存、也没有「上次探测时间」之类的记录:结论只对**探测那一刻**成立(这正是探测这个词的 - * 意思),缓存它就会拿一个几秒前的乐观结论去发一条这一刻发不出去的消息。代价上也不成立—— - * 每条消息**最多探一次**(只有 WS 未就绪那一刻才问),而健康端点就是一次内存读。 - * - * ## 这条链真正是怎么走的(W44 逐段核对,真机回归的判据依据) - * 1. `ChatViewModel.probeReachability`(`ui/chat/ChatViewModel.kt`)**每次都现取一次** client: - * `container.connectivityRepository` → `AppContainer.healthApi`(按 `authManager.currentBaseUrl` - * 缓存,地址一变就重建)→ [top.yeij.cyrene.data.api.ApiClientFactory.createProbe]。这一步在 - * **主线程**上完成,且**它在 `try` 里**——建 client 抛的一切(地址为空 / 格式不合法会让 - * `Retrofit.baseUrl` 直接抛)都折成 [Reachability.Unreachable][top.yeij.cyrene.ui.chat.Reachability], - * 连一个字节都不会发出去。 - * 2. 请求本体:[HealthApi.health] 是 `GET <当前 baseUrl>/api/v1/health`,**不带令牌、不带任何 - * 自定义头**(不挂 `AuthInterceptor`,因此也没有 `X-API-Version`)——该端点在服务端挂在公开 - * 路由上(`Cyrene_v2/internal/services/gateway/routes.go:51`),`rest.go:439` 的 `writeJSON` - * 把它包成 `{"data":{"status":"ok"}}`,正好是 [top.yeij.cyrene.data.api.ApiEnvelope] + `HealthDto` - * 要的形状。 - * 3. 成败判据就一条:`apiCall` 拿到 **2xx 且 `data` 非空** ⇒ `Result.success(true)`;非 2xx、 - * 「2xx 但回执解析不出来」、连不上、超时、抛异常 ⇒ `Result.failure`。上层 - * [top.yeij.cyrene.ui.chat.reachabilityOf] 再把它压成两档,**fail-closed**。 - * - * ## 失败时它在外面长什么样:三种成因**完全同形**(都只是「消息进了队列」) - * - **(甲) 请求压根没出去**:`createProbe` / `Retrofit.baseUrl` 在发出前抛异常(baseUrl 为空或 - * 格式不合法)。此时 socket 上一个字节都没有。 - * - **(乙) 连上了但一个字都没写**:`createProbe` 的 `callTimeout` 是**整通上限**(含连接), - * 而探测从不复用连接池 ⇒ 每次都要现做一次 TCP 握手,预算只有 2.5 秒(`PROBE_TIMEOUT_MILLIS`), - * 比共用客户端的 10 秒**连接**预算紧 4 倍。连接比它慢时 OkHttp 在写请求之前就放弃了: - * 对端只看到「连上又不说话」,**日志里一条请求行都不会有**。 - * - **(丙) 请求出去了、对端也回了非 2xx**(或回执解析不出来):这一档**会**在对端留下记录。 - * - * ⇒ 所以「代理日志里没有 `/api/v1/health`」**不能**单独推出「App 没探过」:(甲)(乙) 都不留痕, - * 只有 (丙) 留痕。要判定必须看**客户端这一侧**(W44 起 DEBUG 下探测客户端挂了 BASIC 日志, - * 见 [top.yeij.cyrene.data.api.ApiClientFactory.createProbe]): - * - logcat 里**没有** `--> GET .../api/v1/health` ⇒ (甲):死在建 client 那一步,去查 App 里 - * 配置的服务器地址(空 / 少了 scheme / 多了非法字符); - * - 有 `-->` 且随后是 `<-- HTTP FAILED: java.io.InterruptedIOException: timeout` ⇒ (乙): - * 2.5 秒的整通上限被连接吃掉了(`ConnectException` 则是对端根本没在听,那是「路真的断了」, - * 与客户端无关); - * - 有 `-->` 且随后是 `<-- 2xx/非 2xx` ⇒ (丙):网络那一段没问题,看回执本身。 - */ -class ConnectivityRepository( - private val api: HealthApi, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO, -) { - - /** 这一刻后端在不在。**永不抛异常**:一切失败都折进 `Result.failure`。 */ - suspend fun probe(): Result = withContext(dispatcher) { - apiCall { api.health() }.map { true } - } -} diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsState.kt b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsState.kt index 79da667..6cb2e66 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsState.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsState.kt @@ -27,8 +27,9 @@ enum class WsState { * `503`、`502`,或握手被打断)在 [ChatSocket.isAuthFailure] 里判不出鉴权失败 ⇒ 走 * [Reconnecting] 按退避**继续重连**。判据只认 `response.code == 401`(拿不到 response 时再退化 * 成消息文本里含 `401`),所以「代理回 503」时 socket 的真值是 [Reconnecting],不是 [Offline]。 - * 这一条在 W41/W44 的发送阶梯上很要命:它决定了「WS 未就绪」时该不该去探一次健康端点 - * ([top.yeij.cyrene.ui.chat.sendRouteFor] 只在真 [Offline] 上跳过探测结论)。 + * 这一条在发送阶梯上很要命:它决定了「WS 未就绪」时**要不要**去试一次 REST + * ([top.yeij.cyrene.ui.chat.sendRouteFor] 只在真 [Offline] 上判进队列、不试;W41 起如此, + * W45 起同一条判据仍然成立,只是那一步从「探一次健康端点」换成了「直接试 REST」)。 */ Offline, } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index 59abeca..18d7d87 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -736,10 +736,11 @@ data class ChatUiState( * 换 URL 再发(见 [deliverImage])——服务端把 `image` **原样落库**,内联的字节会在每一页 * 历史与每一次 `history` 回放里重传,上传是唯一能把这个成本买断的手段。 * - * **本该走 WS 的那条短文本**(W41 起)再走一道三级阶梯([sendThroughLadder],判据是纯函数 - * [sendRouteFor]):WS 就绪 ⇒ 照旧走 WS(成功路径零改动)→ 未就绪 ⇒ 短探一次后端连通性 - * (`GET /api/v1/health`,[probeReachability])⇒ 通就**回落 REST** 把这条发出去、不通才进队列。 - * 于是「未连接」不再直接等于「没法发」:网关只有一条,`/ws` 那半边的握手被挡时 REST 往往是通的。 + * **本该走 WS 的那条短文本**(W41 起)再走一道阶梯([sendThroughLadder],判据是纯函数 + * [sendRouteFor]):WS 就绪 ⇒ 照旧走 WS(成功路径零改动)→ 未就绪 ⇒ **直接试 REST** + * ([sendViaRestFallback])把这条发出去,试不成才进队列。**没有任何连通性探测**——W45 删掉了 + * W41 那道 `GET /api/v1/health`,理由见 [sendRouteFor]。于是「未连接」不再直接等于「没法发」: + * 网关只有一条,`/ws` 那半边的握手被挡时 REST 往往是通的。 * 回落发出去的那条,回复靠重连时的正向增量([gapCursor] / [catchUp])补回来,见 * [armGapCursorForFallback] 与 [sendViaRestFallback]。 * @@ -763,7 +764,7 @@ data class ChatUiState( * 键的复用只发生在用户手动重发的那一刻。唯一会自动重发的是**一次都还没上过路**的那几条 * (WS 未就绪 ⇒ 进 [ChatUiState.queuedForConnection],连接恢复后按发送顺序补发)——那不是重试, * 是这条消息的**第一次**投递,用的还是同一个键,所以「补发」与「手动重发」在服务端是同一种判重。 - * 回落 REST 也失败(网络类除外的一切另说,见 [restFailureLanding])时同样进这个队列, + * 试 REST 也失败(网络类;服务端给过裁决的那些另说,见 [restAttemptLanding])时同样进这个队列, * 用的还是同一个键——**同一条消息无论在路上摔了几次,服务端都只认一条**。 * * ## 单条消息的编辑 / 删除(D3) @@ -895,7 +896,7 @@ class ChatViewModel( /** * 有补发协程在跑(**单飞闸**,见 [flushQueuedSends])。 * - * W41 起补发那条路会挂起(探测与回落 REST),所以「两个触发点都在主线程上 ⇒ 不可能交错」这条 + * W41 起补发那条路会挂起(要发那一次 REST),所以「两个触发点都在主线程上 ⇒ 不可能交错」这条 * 旧论证不再成立,改用这一位:同一时刻最多一个补发循环,而它串行地一条一条走。只在 * [flushQueuedSends] 里置真、只在那一个协程的 `finally` 里置假——没有第三个地方碰它。 */ @@ -1183,9 +1184,10 @@ class ChatViewModel( } /** - * 发送:先乐观上屏,再按路由决定走哪条上行通道;**本该走 WS 而 WS 未就绪时先探一次**—— - * 探测通就回落 REST 发出去,不通才留在待发态等连接([ChatUiState.queuedForConnection], - * 这不算失败,见 [sendThroughLadder]),其余失败回滚并把文本 / 图片 / 附件还给输入框。 + * 发送:先乐观上屏,再按路由决定走哪条上行通道;**本该走 WS 而 WS 未就绪时直接试 REST** + * (W45 起不再先探连通性,理由见 [sendRouteFor])——试成了就是发出去了,试不成才留在待发态等 + * 连接([ChatUiState.queuedForConnection],这不算失败,见 [sendThroughLadder]);服务端给过裁决 + * 的失败回滚并把文本 / 图片 / 附件还给输入框。 * * 路由 [ChatSendRouting]:**短文本且无图无附件 → WS**(直达、绕过有损总线,今天就能用); * **长文本、任何图片、任何附件 → REST `stream:true`**。REST 起的回合**同样在已连接的 WS 上 @@ -1547,23 +1549,26 @@ class ChatViewModel( } /** - * 三级阶梯(W41 主人第二次定案:**未连接时先探一次,能发就发,不通才排队**)。 + * 阶梯(W45 主人定案:**直接试 REST**,不再先探一次)。 * 首次发送([deliver] 的 WS 分支)与队列补发([drainQueuedSends])共用这一个函数—— * 共用是刻意的:阶梯只要有两份实现,两份迟早会分叉成两种行为。 * - * ## 走法(判据全在纯函数 [sendRouteFor],这里只落地) + * ## 走法(判据全在纯函数 [sendRouteFor] 与 [restAttemptLanding],这里只落地) * 1. **WS 就绪 ⇒ 照旧走 WS**。这一段与 W40 之前逐字相同:`socket` 与连接状态各读一次 * (必须是**同一次**观察:`sendChat` 判的是 socket 自己的状态,而界面那一格是经 collect * 镜像过来的、可能晚一帧),帧交出去就什么都不做。**成功路径一个字都不许变**。 - * 2. **未就绪 ⇒ 探一次**([probeReachability]):通 ⇒ [sendViaRestFallback] 用既有的 - * `POST /api/v1/chat` 把这条发出去(短文本也走 REST);不通 / 超时 ⇒ 进队列。 - * 3. **回落 REST 也失败**(网络类)⇒ 进队列:重发仍用同一个 `clientMsgId`,服务端判重 - * (见 [restFailureLanding])。 + * 2. **未就绪(且不是 [WsState.Offline])⇒ 直接试 REST**([sendViaRestFallback]):走既有的 + * `POST /api/v1/chat` 与既有的 [chatRequest](**同一个 `clientMsgId`**)把这条发出去 + * (短文本也走 REST)。这一步**没有任何探测**——真正的请求本身就是那次检验 + * (为什么删掉探测见 [sendRouteFor])。 + * 3. **[WsState.Offline] ⇒ 直接进队列**,试都不试:那一档会话已知是死的([sendRouteFor] 第 2 条)。 + * 4. **试 REST 的结果按 [restAttemptLanding] 落**:服务端收下 ⇒ 完成(**不进队列**); + * 网络类失败 / 没等到裁决 ⇒ 进队列;服务端给过裁决 ⇒ 回滚 + 文案。 * - * ## 一次发送最多一次探测 - * [probeReachability] 在这里只被调用**一次**,且只在「WS 没把帧交出去」之后——WS 就绪那条 - * 成功路径上一次探测都不发(这是「成功路径零改动」的一部分)。补发那条路会再探一次,但那是 - * **另一次投递**:同一条消息(同一个 `localId`)不会因为一次投递而打出一串探测请求。 + * ## 一次发送最多一次 REST + * [sendViaRestFallback] 在这里只被调用**一次**,且只在「WS 没把帧交出去、且不是 Offline」之后 + * ——WS 就绪那条成功路径上一次请求都不发(这是「成功路径零改动」的一部分)。补发那条路会再试 + * 一次,但那是**另一次投递**:同一条消息(同一个 `localId`)不会因为一次投递而打出一串请求。 * * ## 已知边界:打断声明不随补发重放 * [deliver] 里那个打断声明(`interrupt{scope:"current"}`)发生在通道分支**之前**,未连接时它 @@ -1581,7 +1586,7 @@ class ChatViewModel( parallel: Boolean, fromQueue: Boolean, ) { - // socket 只读一次:下面三处必须是**同一次**观察(成功后不再读,探测与队列判定共用这一份)。 + // socket 只读一次:下面两处必须是**同一次**观察(成功后不再读,路由判定共用这一份)。 val ws = socket val sent = ws?.sendChat(sessionId, text, DEVICE_ID, localId, parallel) ?: false val state = ws?.state?.value ?: WsState.Idle @@ -1599,16 +1604,11 @@ class ChatViewModel( rollback(localId, SEND_FAILED_MESSAGE) } - // 未就绪 ⇒ 先探一次再决定(W41)。探测失败 / 超时一律按不通处理 - // ([reachabilityOf] 的 fail-closed)⇒ 落回 W40 的队列与那套 UI,不新造。 - // - // 探测是**作为实参**算出来的 ⇒ 四档未就绪**都会真的发一次请求**,包括 [WsState.Offline] - // 那一档([sendRouteFor] 在那里不看这个值,结论被丢掉:路由结果与不探逐字相同, - // 代价只是白发一次请求)。写在这里是因为「Offline 连探测都不做」这句话只在判据层面 - // 成立,读代码时极易当成「那条路上不产生请求」——W44 的诊断正是卡在这个歧义上。 - WsSendOutcome.Queued -> when ( - sendRouteFor(wsDispatched = false, connection = state, reachability = probeReachability()) - ) { + // 未就绪 ⇒ 直接试 REST(W45)。判据是纯函数 [sendRouteFor],这里只落地:三档「还没 + // 连上」(Idle / Connecting / Reconnecting)试一次;[WsState.Offline] 那一档直接进 + // 队列,**一次请求都不发**(那一档会话已知是死的,试了只会把「已排队不会丢」换成 + // 一次回滚 + 一句误导人的鉴权错误)。 + WsSendOutcome.Queued -> when (sendRouteFor(wsDispatched = false, connection = state)) { SendRoute.Rest -> sendViaRestFallback(localId, text, parallel, fromQueue) // Ws 在这一支不可达(刚刚才问过 `sendChat`,它没把帧交出去);真出现也只可能是 @@ -1619,55 +1619,36 @@ class ChatViewModel( } /** - * 探一次后端连通性(W41):`GET /api/v1/health`,短超时,**不缓存**。 - * - * 结论只对**探的那一刻**成立,所以每次走到这一步都现问一次;而每条消息最多走到这里一次 - * (只有 WS 没把帧交出去才问),代价是一次内存读 + 一个本地来回,值。 - * - * ## 这一层是**全定义**的,永不抛异常 - * 失败 / 超时 / 抛异常一律 [Reachability.Unreachable]。`try` 覆盖的还不只是那一次请求 - * ([ConnectivityRepository.probe] 自己已经把网络层的一切折进 `Result` 了),还包括**把那次 - * 调用本身**:客户端是按当前 baseUrl 现建的,baseUrl 还没配好 / 格式不对时,建 Retrofit - * 那一步就会抛——那是配置问题,此刻的结论就是「不通」,该做的是把这条消息留在队列里 - * (用户修好地址、连接一就绪就自动补发),**不是**让一次发送把异常抛到界面上去。 - * - * 取消照旧抛出去(它是取消,不是「探测失败」,吞掉会让协程无法被取消)。 - * - * ## 一个 `Unreachable` 有三种成因,而它们在外面**完全同形**(W44 判定结论) - * 「请求压根没出去」「连上了但一个字节都没写(连接吃掉了 2.5 秒的整通上限)」「请求出去了、 - * 对端回了非 2xx」在这里都被折成同一个值,最后只表现为「消息进了队列」——W41 的真机回归 - * 就是这么卡住的(代理日志里一条 `/api/v1/health` 都没有,却分不清是哪一档)。三档的成因、 - * 怎么用 logcat 分开,逐条写在 [ConnectivityRepository] 的类注释里(那份是本条链的判据来源)。 - * - * 注意 `catch` 只认 `Exception`:**断言 / OOM 之类不会**被吞成「不通」,它们照旧抛出去。 - */ - private suspend fun probeReachability(): Reachability = try { - reachabilityOf(container.connectivityRepository.probe()) - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - Reachability.Unreachable - } - - /** - * 回落 REST(W41):WS 未就绪、而探测说「这一刻这个后端在」,就用既有的 - * `POST /api/v1/chat`(`stream:true`)把这条发出去。 + * 直接试 REST(W45;形状与 W41 一样:WS 未就绪时用既有的 `POST /api/v1/chat`(`stream:true`) + * 把这条发出去)。 * * ## 为什么短文本也走 REST * 走 WS 本来就是**优选**(直达、绕过有损总线),不是契约要求;`POST /api/v1/chat` 从第一天起 * 收的就是同一种消息,两者在服务端汇进同一个编排器、同一个幂等键、同一份落库。所以「WS 那半 * 边不通时改走 REST」只是换一条车道,不是换一种语义。 * + * ## 为什么这一次请求本身就是判据(W45 删掉了探测) + * 用的是**同一个** [chatRequest](同一个 `clientMsgId`——它就是那条乐观气泡的 `localId`): + * 成了 ⇒ 消息发出去了(**不进队列**),败了 ⇒ 进队列,而重发仍是同一个键,服务端按 + * `(session_id, client_msg_id)` 判重,不会落成第二条。落点判据是纯函数 [restAttemptLanding], + * 为什么不再先探一次、探测的代价长什么样,见 [sendRouteFor]。 + * + * ## 「服务端收下了」怎么判(不用等整轮生成完) + * 回执是一个**小 JSON**,不是内容流:收下 ⇒ `200 {stream:true}` / `202 {queued:true}` / + * `200 {duplicate:true}`([ChatAck.isAccepted] 那一族);拒绝 ⇒ `409` / `500` / `503` / `400`。 + * 内容帧只走已连接的 WS,本端要判的只是「服务端收下了没有」——逐条论证与文件:行号见 + * [restAttemptLanding] 的 KDoc。 + * * ## 回复为什么收不到、又怎么补回来 * 内容帧只走**已连接的 WS** 广播,而此刻没有连接 ⇒ 那一轮回复不会实时到达本端。它靠重连时的 * 两段既有链路补回来:重连那次**正向增量**([gapCursor] + [catchUp])与握手 `history` 回放 * (见 [armGapCursorForFallback] 与 [absorb])。这条消息自己落库的那条则由 * `message_persisted` / 补拉里的 `clientMsgId` 归一([reconcilePending])。 * - * ## 两道有界 - * 整通调用有上限 [REST_FALLBACK_TIMEOUT_MILLIS](超时按网络类失败 ⇒ 进队列);「离线」时 - * 真正会等的只有探测那一段([top.yeij.cyrene.data.api.PROBE_TIMEOUT_MILLIS]),因为探测不通 - * 就进队列、一次 REST 都不会发。 + * ## 两道有界(无网时的等待就是这两个数里更紧的那个) + * 连不上 ⇒ client 上的短连接超时([top.yeij.cyrene.data.api.CONNECT_TIMEOUT_MILLIS],2.5 秒) + * 内失败 ⇒ 进队列;连上了却一直不回话 ⇒ 整通上限 [REST_FALLBACK_TIMEOUT_MILLIS](10 秒)到点 + * ⇒ 也进队列。**完全无网时用户最多等 ~2.5 秒**,这条性质一个字都没丢(W41 是 ≤2.5 秒)。 * * @param fromQueue true = 这是补发:失败时不再次入队(它本来就在队里),只让它留在队里等下一次。 */ @@ -1677,7 +1658,8 @@ class ChatViewModel( parallel: Boolean, fromQueue: Boolean, ) { - val ack = withTimeoutOrNull(REST_FALLBACK_TIMEOUT_MILLIS) { + // 整通上限(第二个超时)就加在**这一次投递**上,理由见 [REST_FALLBACK_TIMEOUT_MILLIS]。 + val attempt = withTimeoutOrNull(REST_FALLBACK_TIMEOUT_MILLIS) { // 回落这条路上永远没有图片与附件:能走到这里的只有「内容是短文本、本该走 WS」的那几条 // (有图 / 有附件一律被 [ChatSendRouting.channelFor] 判成 Rest,走的是上面另一支, // 根本不进这层阶梯)。 @@ -1686,39 +1668,36 @@ class ChatViewModel( ) } - // 超时(`null`)与网络类失败同一档:**服务端没给过裁决** ⇒ 这条还没上路 ⇒ 进队列。 - // 重发用的是同一个 `clientMsgId`,服务端若其实已经收下,只会回 `duplicate`(不会落两条)。 - if (ack == null) { - onRestFallbackUnsent(localId, parallel, fromQueue) - return - } - - ack.onSuccess { received -> - // 只有「服务端已经收下这条」才该往下走这两步:被拒的那些走 [onAck] 的回滚,那条消息 - // 没上路,既没有回复要等,队列也由 [rollback] 顺手出掉。 - if (received.isAccepted()) { + // 三档落点全在纯函数里判:`null`(整通上限到点)= 服务端没给过裁决 ⇒ 与网络类失败同一档。 + when (val landing = restAttemptLanding(attempt)) { + // 服务端收下了 ⇒ **就是发出去了**:把「回复要靠重连补拉」的游标准备好。 + is RestAttemptLanding.Accepted -> { armGapCursorForFallback() // 补发这一条**必须出队**:服务端已经收下 ⇒ 这条了结了。不出队的话它会一直挂在 // 「排队中 · 还没连上」的小标上(界面上那句话就成了假话),下一次重连还会再补发 // 一遍——服务端虽然按同一个 `clientMsgId` 判重、不会落两条,但那是一次白发的请求。 // 这也是「补发真的成功」的另一种形态 ⇒ 顺手把反复失败的记账归零(同 Dispatched 那支)。 if (fromQueue) dropQueuedSend(localId, sent = true) + // 受理类与拒绝类都交给既有那六支(拒绝的回滚与文案都在 [onAck] 里)。 + onAck(localId, landing.ack) } - onAck(localId, received) - }.onFailure { e -> - val error = (e as? CyreneException)?.error - when (restFailureLanding(error)) { - RestFailureLanding.Queue -> onRestFallbackUnsent(localId, parallel, fromQueue) - RestFailureLanding.Rollback -> rollback(localId, e.userMessage()) - } + + // 这条还没上路 ⇒ 进队列,连接一好按同一个键补发。 + RestAttemptLanding.Unsent -> onRestFallbackUnsent(localId, parallel, fromQueue) + + // 服务端给过裁决(队列满 / 接不住 / 明确拒绝)⇒ 既有那六支:回滚 + 文案。 + is RestAttemptLanding.Refused -> onAck(localId, landing.ack) + + // 本端异常(401 / 配置类 / 映射不出来的服务端错误)⇒ 回滚 + 文案,与 W41 一个字没变。 + is RestAttemptLanding.Errored -> rollback(localId, landing.error.userMessage) } } /** - * 回落 REST 也没把这条送出去时的落点。 + * 试 REST 也没把这条送出去时的落点。 * - * 补发时(`fromQueue`)**什么都不做**:它本来就在队列里,位置也没变,等下一次 Connected - * (或下一次探测)再来。这里必须什么也不做——再入队一次会让同一条在队列里出现两份, + * 补发时(`fromQueue`)**什么都不做**:它本来就在队列里,位置也没变,等下一次 Connected 再来 + * (补发那次仍走同一个阶梯)。这里必须什么也不做——再入队一次会让同一条在队列里出现两份, * 而补发是照队头走的,那等于补发两次。 */ private fun onRestFallbackUnsent(localId: String, parallel: Boolean, fromQueue: Boolean) { @@ -1749,12 +1728,12 @@ class ChatViewModel( /** * 记下「这条没连上、留在本端等连接」,并在该说的时候说一句(W40 主人裁决:未连接就进队列)。 * - * ## 它在 W41 里的位置 - * 它是**阶梯的最后一级**([sendThroughLadder]):WS 没把帧交出去、探测又说后端不通(或这一档 - * 压根不该探测),才落到这里。调用点两处,都走 [onRestFallbackUnsent],且都只在 + * ## 它在阶梯里的位置(W45) + * 它是**阶梯的最后一级**([sendThroughLadder]):WS 没把帧交出去、直接试的那次 REST 又没成功 + * (网络类失败 / 整通上限到点),才落到这里。调用点两处,都走 [onRestFallbackUnsent],且都只在 * **首次发送**(`fromQueue = false`)时: - * - 未就绪 + 探测不通 ⇒ 直接进队列; - * - 未就绪 + 探测通 ⇒ 回落 REST,而那次 REST 也失败(网络类 / 超时)⇒ 进队列。 + * - 未就绪 + [WsState.Offline] ⇒ 直接进队列(那一档不试 REST); + * - 未就绪 + 试 REST ⇒ 那次没成功(网络类 / 超时)⇒ 进队列。 * * 三件事,顺序也就是优先级: * 1. **入队**([ChatUiState.withQueuedSend])——气泡照旧留在屏幕上(它本来就已经是乐观气泡了), @@ -1804,17 +1783,17 @@ class ChatViewModel( * - 连接就绪却仍发不出去([WsSendOutcome.Failed],只剩 socket 对象已空 / 序列化抛异常那两种成因) * 或这条补发不了([QueuedSendStep.Failed])⇒ 出队 + 走今天那条回滚 + 文案:**绝不静默吞掉**。 * - * ## W41:补发走的是**同一个阶梯** + * ## 补发走的是**同一个阶梯**(W41 起) * 每一条都交给 [sendThroughLadder](`fromQueue = true`)——也就是首次发送走的那一个函数, - * 所以「WS 优先 → 探测 → 回落 REST → 队列」这套判据在两条路上**只有一份**。今天队列里只可能 - * 有「内容是短文本、本该走 WS」的那几条(能入队的两处:WS 分支没交出去、以及回落 REST 失败, + * 所以「WS 优先 → 直接试 REST → 队列」这套判据在两条路上**只有一份**。今天队列里只可能有 + * 「内容是短文本、本该走 WS」的那几条(能入队的两处:WS 分支没交出去、以及试 REST 没成功, * 两者都只发生在短文本上),而本函数**只在 WS 就绪时被触发**(两个触发点各自都先看过 * `Connected`)⇒ 实际落到的永远是阶梯第一步;后面两步是给「触发与执行之间连接又变了」这种 * 窗口留的,判据一样,不需要另写一套。 * * ## 「同一条不会被补发两次」的保证点 * **队头 + 出队**:每一步只处理队头,而队头只在这一步有了结论之后才被摘掉([dropQueuedSend], - * 回滚那一支由 [rollback] 顺手出队)。本函数现在**有挂起点**了(W41 起阶梯里要探测、要发 REST), + * 回滚那一支由 [rollback] 顺手出队)。本函数现在**有挂起点**了(W41 起阶梯里要发那次 REST), * 所以「两个触发点在主线程上不可能交错」这条旧论证不再成立,改用**单飞**:[flushQueuedSends] * 用 [drainingQueue] 保证同一时刻最多一个补发协程在跑,而它从头到尾是**串行**的(一条有了结论 * 才看下一条)。于是重复触发(连接抖动、重组、刚连上又断)只会撞上那道闸,不会多出第二次投递。 @@ -2077,8 +2056,8 @@ class ChatViewModel( * 才是 `true`,判据见 [parallelForSend])——默认档的请求体因此逐字节不变。 * * 失败一律回滚(今天的样子,一个字没变):这条路的两种入口(长文本、带图 / 带附件的消息)本来 - * 就是 REST,它们不在 W41 那条「先探测再决定」的阶梯上——那条阶梯只管「本该走 WS 的短文本」 - * (见 [sendThroughLadder])。 + * 就是 REST,它们不在 [sendThroughLadder] 那道阶梯上——那道阶梯只管「本该走 WS 的短文本」, + * 在那道阶梯上试 REST 只有在 WS 没把帧交出去时才发生([sendViaRestFallback])。 */ private suspend fun sendViaRest( localId: String, @@ -2165,18 +2144,6 @@ class ChatViewModel( else -> rejected.message.ifBlank { "发送失败,请重试" } } - /** - * 这份回执照的是「**服务端已经收下这条**」(受理起回合 / 忙时续接排队 / 早就收过这条幂等键)。 - * - * 只有这一族才需要 [armGapCursorForFallback]:被拒的那三种(队列满 / 接不住 / 明确拒绝)走 - * [onAck] 的回滚,那条消息没有上路,也就没有回复要等。用 `when` 穷举而不是 `!=` 某个值: - * 回执面将来多一档时,这里必须有人做决定。 - */ - private fun ChatAck.isAccepted(): Boolean = when (this) { - ChatAck.Accepted, is ChatAck.Queued, is ChatAck.Duplicate -> true - ChatAck.QueueFull, is ChatAck.Unavailable, is ChatAck.Rejected -> false - } - /** * 「这条早已受理过」的对齐:去掉那条乐观气泡;本地若**没有**这条消息,再补拉最新一页。 * diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendFallback.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendFallback.kt index 5074042..d8e10d2 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendFallback.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendFallback.kt @@ -1,16 +1,31 @@ package top.yeij.cyrene.ui.chat import top.yeij.cyrene.core.error.CyreneError +import top.yeij.cyrene.data.api.CyreneException +import top.yeij.cyrene.data.repository.ChatAck import top.yeij.cyrene.data.ws.WsState /** - * 回落 REST 那一次投递的**整通上限**(毫秒)——`POST /api/v1/chat` 从发出到拿到回执。 + * 回落 REST 那一次投递的**整通上限**(毫秒)——`POST /api/v1/chat` 从发出到拿到回执,连接、写、 + * 读全算在内。 * - * 探测通过只说明「这一刻 http 这条路通」,之后那一次真请求仍可能卡住(弱网、半死的代理、服务端 - * 被别的请求堵住)。共用 REST 客户端给的是连接 10s / 读 60s——那是给**已经确定要发的**请求定的, - * 而 `stream:true` 的回执本该毫秒级回来。回落这条路再压一道 10 秒上限,是为了让「没网」时的等待 - * 有上界可算:用户最多等 探测([top.yeij.cyrene.data.api.PROBE_TIMEOUT_MILLIS])+ 回落(本值), - * 不会以为界面卡死。 + * ## 一个 client、两个超时(W45) + * 它和 client 上的 [top.yeij.cyrene.data.api.CONNECT_TIMEOUT_MILLIS] 是**两个分开的闸**,管的是 + * 两件不同的事: + * - **连接超时(2.5 秒,挂在 client 上)**管「连不上」:完全无网、死网、对端不在听时 2.5 秒内 + * 失败 ⇒ 进队列。它是「无网快速进队列」这条性质的**全部**依据; + * - **本值(10 秒,挂在这一次投递上)**管「连上了但一直不回话」:半死的代理、接住连接却不回应 + * 的服务端,到点按网络类失败处理 ⇒ 进队列。 + * + * 本值是 W41 就写在纸上的那一个,**不许拉长**:它直接是用户按完发送键之后的干等时间上界。正常 + * 网络下这一次本该毫秒级拿到回执(「服务端收下了」的判据见 [restAttemptLanding]),10 秒是 + * 「回得慢」与「这条路已经死了」之间的分界。 + * + * ## 为什么它不写成 client 的 `callTimeout` + * 这个 client 是**共用**的:长文本、内联图片那两条本来就该走 REST 的路用的是同一个 + * ([ChatViewModel.sendViaRest])。client 级 `callTimeout` 会把它们一并框住——一条慢但合法的 + * 请求会被判成失败,那是给一条本轮不打算碰的路引入新失败面。放在调用点则「哪一次投递有整通上限」 + * 是显式的,就写在 [ChatViewModel.sendViaRestFallback] 那一行上。 * * 超时**不当作「服务端拒绝」**:那条消息可能已经被服务端收下(只是回执没回来)⇒ 按网络类失败 * 处理 ⇒ 进队列,重发时仍用同一个 `clientMsgId`,服务端按 `(session_id, client_msg_id)` 判重后 @@ -18,30 +33,13 @@ import top.yeij.cyrene.data.ws.WsState */ const val REST_FALLBACK_TIMEOUT_MILLIS = 10_000L -/** - * 一次连通性探测的结论([reachabilityOf] 的取值)。 - * - * **只有两档**,没有第三档「不确定」:探测失败 / 超时 / 抛异常 / 后端自称不健康,都归 - * [Unreachable](见 [reachabilityOf])。理由是这个结论**要直接决定一条消息的去向**,而两档的 - * 代价不对称:误判成 [Reachable] 会发一次注定失败的 REST(用户等一个来回、最后还是回滚), - * 误判成 [Unreachable] 只是走今天的队列(内容一个字节都不会丢,连接一好就自动补发)。 - * 所以不确定一律往队列那一侧倒(fail-closed)。 - */ -enum class Reachability { - /** 后端在这一刻**真的回了话**(`GET /api/v1/health` 拿到 2xx 且回执能解析)。 */ - Reachable, - - /** 其余一切:超时、连不上、DNS 不解析、非 2xx、回执解析不出来、后端自称不健康。 */ - Unreachable, -} - /** * 一条消息这一刻该怎么走([sendRouteFor] 的取值)。 * * 与 [top.yeij.cyrene.domain.chat.SendChannel] **不是一回事**,两者正交、同时存在: * - [top.yeij.cyrene.domain.chat.SendChannel] 判的是「**这条内容**该走哪条上行通道」——静态的、 * 只看文本字节数 / 有没有图片 / 有没有附件,与网络状况无关([top.yeij.cyrene.domain.chat.ChatSendRouting]); - * - 本枚举判的是「**这一刻这个动作**该怎么做」——动态的、看连接状态与一次探测的结论。 + * - 本枚举判的是「**这一刻这个动作**该怎么做」——动态的、只看「帧交出去了没有」与连接状态。 * * 阶梯的**第一步**永远是 `SendChannel` 那次判定(有图 / 有附件的消息根本进不到下面这两档, * 它们本来就是 REST);本枚举只在「内容是短文本、本该走 WS」那条路上生效。 @@ -50,10 +48,16 @@ enum class SendRoute { /** 帧已经交给 socket(或连接就绪、就该走 WS):照今天的样子继续等回执。 */ Ws, - /** WS 未就绪、而探测说后端通 ⇒ **回落 REST** 把这条发出去(短文本也走 REST)。 */ + /** + * WS 没把帧交出去、而连接不是 [WsState.Offline] ⇒ **直接试 REST** 把这条发出去 + * (短文本也走 REST:`POST /api/v1/chat` 从第一天起收的就是同一种消息)。 + * + * 这里**没有任何探测**(W45 删掉了 W41 那道 `GET /api/v1/health`):真正的 REST 请求本身就 + * 是最直接的检验——试成了这条还顺手发出去了,试败了才进队列。理由见 [sendRouteFor]。 + */ Rest, - /** WS 未就绪、而探测说后端不通(或压根不该探测)⇒ 进补发队列等连接。 */ + /** 连接是 [WsState.Offline] ⇒ **不试 REST**,直接进补发队列等连接。 */ Queue, } @@ -69,65 +73,154 @@ enum class RestFailureLanding { } /** - * 「这条 `chat` 帧交出去了没有 + 这一刻的连接状态 + 一次探测的结论」⇒ 该走哪条路 - * (**纯函数**,VM 与测试共用)。 + * 「这条 `chat` 帧交出去了没有 + 这一刻的连接状态」⇒ 该走哪条路(**纯函数**,VM 与测试共用)。 * - * ## 为什么是「先探测再决定」,而不是直接排队(主人 2026-10-05 的第二次定案) - * W40 的裁决是「未连接就进队列」,它解决的是**内容不被退回给用户**(不报错、不丢字)。但 WS 断着 - * 不等于后端不可达:网关只有一条,`POST /api/v1/chat` 与 `/ws` 是同一个 `:8080` 上的两条车道, - * 握手被挡(代理不转发 Upgrade、中间设备掐长连接、只断了 WS 那一半)时 REST 往往是通的。 - * 直接排队等于把「WS 这一条车道暂时不通」当成「后端不可用」,让用户白等一次重连——而那时候 - * **这条消息本来就能发出去**。所以顺序改成:能走 WS 就走 WS(成功路径一个字不动)→ 走不了就 - * 花一次短探测问一句「后端在不在」→ 在就回落 REST 发出去,不在才排队。 + * 判据里**没有任何探测输入**(W45):路由只看这两个事实,`GET /api/v1/health` 那一层连同它的 + * 客户端、独立超时预算与失败面一起删掉了。 + * + * ## 为什么是「直接试 REST」(W45 主人的定案) + * W41 的阶梯是「WS 未就绪 ⇒ **先探 `GET /api/v1/health`** ⇒ 通才回落 REST」。真机回归 + * (`devtools/ws_block_proxy.py` 只掐 `/ws/chat`、HTTP 全放行)发现消息**进了队列而不是回落 + * REST**;而 `ProbeClientTest` 证明那条链在 JVM 上是通的,`Unreachable` 生在**连接之前**,三种 + * 成因(建 client 抛 / 连上但一个字没写 / ConnectException)**在外同形** ⇒ 那一轮无法定论。 + * + * 结论是**不该有探测**:探测只是「能不能连上」的**替身**,而真正的 REST 请求本身就是最直接的 + * 检验——试成了消息还顺手发出去了,试败了进队列(内容一个字节不丢,重发仍是同一个 + * `clientMsgId`)。探测唯一省下的是「完全无网时少发一个注定失败的请求」,代价却是一个独立 + * client、一套独立超时预算和一整套新的失败面;而那个探针 client **从不复用连接池**,每次都要现做 + * 一次冷握手,预算还比主链路紧 4 倍(2.5 秒整通 vs 10 秒连接)——今天这个 bug 的形态就长在 + * 这上面。 + * + * 直接试也**不会**把「无网快速进队列」弄丢:那一条靠的是 client 上的短连接超时 + * ([top.yeij.cyrene.data.api.CONNECT_TIMEOUT_MILLIS],2.5 秒),不是探针。 * * ## 判据与优先级 - * 1. `wsDispatched == true` ⇒ [SendRoute.Ws]。帧已经交给 socket,连接状态与探测结论都**不再参与** - * 判断(`sendChat` 判的就是自己的状态)。**成功路径零改动**:不打标记、不说告知、不写任何状态。 - * 2. 连接是 [WsState.Offline] ⇒ [SendRoute.Queue],**判据不看探测结论**。这一档的语义是「握手 401 且 - * 续期也失败」(终态、不会自动重连),也就是说**会话已知是死的**。这时候去探测毫无意义:健康 - * 端点**不鉴权**(`Cyrene_v2/internal/services/gateway/routes.go:51` 把它挂在公开路由里),它必然 - * 回 200,于是判成 [Reachable]、发出一次注定 401 的 REST,最后把 W40 那句「已排队不会丢」换成 - * 一次回滚 + 一句会误导人的鉴权错误。这一档该做的是让用户重新登录 / 检查服务器地址 - * ([SEND_QUEUED_OFFLINE_NOTICE]),队列照旧留着。 + * 1. `wsDispatched == true` ⇒ [SendRoute.Ws]。帧已经交给 socket,连接状态**不再参与**判断 + * (`sendChat` 判的就是自己的状态)。**成功路径零改动**:不打标记、不说告知、不写任何状态。 + * 2. 连接是 [WsState.Offline] ⇒ [SendRoute.Queue]。这一档的语义是「握手 401 且续期也失败」 + * (终态、不会自动重连),也就是说**会话已知是死的**;这时候去试 REST 只会拿到 401 并落到 + * 「回滚 + 一句会误导人的鉴权错误」,把 W40 那句「已排队不会丢」换成更差的结果。**保留 W41 + * 这个决定**:它当时的理由(健康端点不鉴权、必然回 200,于是判成「通」)随探测一起消失了, + * 但结论换到直接试这边**一字不用改**——不鉴权的探针回 200 是「假通」,带令牌的 chat 端点回 + * 401 是**真不通**,两条路都指向同一件事:这一档不该发。 + * 3. 其余三档未就绪([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting]) + * ⇒ [SendRoute.Rest]:**直接试 REST**,不等任何探测。 * - * ⚠️ **「不看」不等于「没探」**:调用方(`ChatViewModel.sendThroughLadder`)是**先算出 - * `reachability` 再把它喂给本函数**的,所以在 [WsState.Offline] 上那一次探测**照样会发出去**, - * 只是它的结论在这里被丢掉。行为上无害(路由结果与不探逐字相同,本文件的用例已经用 - * `null` / [Reachable] / [Unreachable] 三种取值钉住),代价是**白发一次请求**。这条真实行为 - * 写在这里是因为它很容易被读成「Offline 时不发请求」——W44 的诊断就差点栽在这个歧义上 - * (见 [top.yeij.cyrene.data.repository.ConnectivityRepository.probe] 的三种成因一节)。 - * 3. 其余三档未就绪([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting])且探测是 - * [Reachability.Reachable] ⇒ [SendRoute.Rest]:回落 REST,把这条发出去。 - * 4. 其余一切(未就绪 + 探测不通 / 超时 / 抛异常 / 还没探过 = `null`)⇒ [SendRoute.Queue]。 - * `null`(没有探测结论)与「探测不通」走同一条路:**没有证据说能发,就不发**。 - * - * 判据只认 `Connected` **一个值**(不是「不等于 Offline」之类的近似)——连接状态的另外四档都 - * 意味着帧没有被交给任何服务端。 + * 判据只认 `Connected` **一个值**(不是「不等于 Offline」之类的近似)——连接状态的另外四档 + * 都意味着帧没有被交给任何服务端。 */ fun sendRouteFor( wsDispatched: Boolean, connection: WsState, - reachability: Reachability?, ): SendRoute = when { wsDispatched -> SendRoute.Ws connection == WsState.Offline -> SendRoute.Queue - reachability == Reachability.Reachable -> SendRoute.Rest - else -> SendRoute.Queue + else -> SendRoute.Rest } /** - * 一次探测的原始产物 ⇒ 结论(**纯函数**)。 + * 这份回执照的是「**服务端已经收下这条**」(受理起回合 / 忙时续接排队 / 早就收过这条幂等键)。 * - * 判据只有一条:`Result` 里是 `true` 才算通。其余全部 [Reachability.Unreachable]—— - * 失败(超时 `SocketTimeoutException` / 连不上 `ConnectException` / DNS / 非 2xx / 回执解析不出来) - * 与「后端自称不健康」是同一档。**fail-closed**:探测这一层永远不抛异常给调用方,也永远不因为 - * 「拿不准」而放行一次真发送。 + * 只有这一族才算了结:被拒的那三种(队列满 / 接不住 / 明确拒绝)不算——那条消息没有上路,走 + * [ChatAck] 那六支里的回滚 + 文案。用 `when` 穷举而不是 `!=` 某个值:回执面将来多一档时, + * 这里必须有人做决定。 * - * 之所以要一个函数而不是一个 `if`:这条「不确定就往队列倒」的规矩要被单测钉住(探测抛异常那条 - * 用例),而它恰好是整条阶梯里唯一没有第二次机会的一步。 + * 它是[直接试 REST][restAttemptLanding] 那一步「**收下了没有**」的**唯一**判据。W45 把它从 + * `ChatViewModel` 的私有方法搬到这里:这条判据决定了一条消息会不会进补发队列(收下了就不该进), + * 不该只活在 VM 里、只被组合着一个界面才测得到。 */ -fun reachabilityOf(probed: Result): Reachability = - if (probed.getOrNull() == true) Reachability.Reachable else Reachability.Unreachable +fun ChatAck.isAccepted(): Boolean = when (this) { + ChatAck.Accepted, is ChatAck.Queued, is ChatAck.Duplicate -> true + ChatAck.QueueFull, is ChatAck.Unavailable, is ChatAck.Rejected -> false +} + +/** + * 一次「直接试 REST」的落点([restAttemptLanding] 的取值)。 + * + * 四档**穷举**「服务端有没有收下 / 有没有给过裁决」这两个问题的组合,每一档对应一个明确的处置, + * 没有「默认继承前一档」的余地。判据决定的是**一条消息的去向**(进队列还是回滚),所以两档的 + * 代价不对称:误判成 [Accepted] 会让一条没上路的消息凭空消失,误判成 [Unsent] 只是多补发一次 + * (幂等键兜住,不落第二条)——拿不准一律往 [Unsent] 倒。 + */ +sealed interface RestAttemptLanding { + /** + * **完成**:服务端收下了这条,[ack] 是它的回执(原样交给既有那六支)。 + * + * 这一档**不进队列**:收下了还排队会白补发一次(幂等键虽然兜得住,但那是浪费,而且会让界面上 + * 那句「排队中 · 还没连上」变成假话)。 + */ + data class Accepted(val ack: ChatAck) : RestAttemptLanding + + /** **进队列**:这条还没上路(分档见 [restAttemptLanding]),等连接一好按同一个键补发。 */ + data object Unsent : RestAttemptLanding + + /** **回滚**:服务端给过裁决(拒绝),回滚与文案都在既有那六支里([ack] 原样交给它们)。 */ + data class Refused(val ack: ChatAck) : RestAttemptLanding + + /** **回滚**:本端异常(401 / 配置类 / 映射不出来的服务端错误),用 [error] 自己的文案。 */ + data class Errored(val error: CyreneError) : RestAttemptLanding +} + +/** + * 这一次「直接试 REST」的产物 ⇒ 落点(**纯函数**,VM 与测试共用)。 + * + * ## 怎么判定「服务端收下了」(不需要等整轮生成完) + * `POST /api/v1/chat` 在 `stream:true` 下的回执**不是**内容流,而是一个小 JSON:服务端把这一轮 + * 交给编排器之后就写回执返回(`Cyrene_v2/internal/services/gateway/chat_stream_dispatch.go:201` + * 的 `writeChatStreamAck`:`200 {"stream":true}` / `202 {stream:true,queued:true}` / + * `200 {duplicate:true}` / `409 chat_busy` / `503 chat_dispatch_failed`),真正的回合跑在它自己 + * 派生的 goroutine 上(同文件 `submitChatStream` 的注释:「streamChat 同步返回提交判定,因此这里 + * 不会阻塞在模型调用上」)。内容帧只走**已连接的 WS**(见 [ChatViewModel.sendViaRestFallback] 的 + * 「回复为什么收不到、又怎么补回来」一段)。 + * + * 所以本端要判的只是「**回执到了没有、它说的是不是收下了**」:[ChatRepository] 拿到 HTTP 响应并 + * 把那个小 JSON 解析成 [ChatAck] 就算到了(`ChatApi.sendChat` 返回的是 + * `Response>`,Retrofit 读完这一小段 body 即返回),**不用**等任何生成。 + * 也就是说这次调用等的是「**服务端收下了**」这个事实,不是那一轮的产出。 + * + * @param attempt `withTimeoutOrNull(REST_FALLBACK_TIMEOUT_MILLIS) { chatRepository.send(request) }` + * 的**原样**产物:`null` = 整通上限到点(本端没等到任何回执);`Result.success` = 服务端回了 + * 回执(受理与拒绝都在里面,靠 [ChatAck.isAccepted] 分开);`Result.failure` = 本端异常 + * (网络 / 配置 / 401)。 + * + * ## 判据与优先级 + * 1. `null`(整通上限到点)⇒ [RestAttemptLanding.Unsent]。本端**没等到任何裁决**:那条消息可能 + * 已经被服务端收下(只是回执没回来),也可能压根没到——本端分不出来,而**分不出来就往队列 + * 倒**(fail-closed)。重发还是同一个 `clientMsgId`,服务端若其实已经收下,只会回 + * `duplicate`(不会落成第二条)。 + * 2. `Result.success` 且 [ChatAck.isAccepted] ⇒ [RestAttemptLanding.Accepted]:**服务端收下了**, + * 就是发出去了,**不进队列**。 + * 3. `Result.success` 且不被受理 ⇒ [RestAttemptLanding.Refused]:服务端**给过裁决**(`409` 队列满 / + * `500` 接不住 / `503` 派发失败 / `400` 别的明确拒绝)⇒ 走今天那条回滚 + 文案,绝不偷偷重发。 + * 4. `Result.failure` ⇒ 交给 [restFailureLanding](**W41 那套口径一字未改**):网络族 + * (`NoNetwork` / `Dns` / `Timeout` / `ConnectFailed`)与「异常映射不出来」⇒ + * [RestAttemptLanding.Unsent];服务端裁决类([CyreneError.Server] / [CyreneError.Unauthorized]) + * 与配置族(`Tls` / `CleartextBlocked` / `InvalidUrl` / `Parse` / `Unknown`)⇒ + * [RestAttemptLanding.Errored]。 + * + * ## fail-closed 落在哪里 + * 只有第 2 条不进队列,而它要求**回执明确说了「收下了」**。其余每一档不是进队列就是回滚—— + * 没有任何一条会因为「说不定通了」把一条没上路的消息当成发出去了,也没有任何一条会因为 + * 「反正会重试」把服务端的裁决吞掉。 + */ +fun restAttemptLanding(attempt: Result?): RestAttemptLanding = when { + attempt == null -> RestAttemptLanding.Unsent + + attempt.isSuccess -> { + val ack = attempt.getOrThrow() + if (ack.isAccepted()) RestAttemptLanding.Accepted(ack) else RestAttemptLanding.Refused(ack) + } + + // 异常不是 CyreneException(映射不出来)= 本端说不清服务端有没有收下 ⇒ 与网络族同一档。 + else -> when (val error = (attempt.exceptionOrNull() as? CyreneException)?.error) { + null -> RestAttemptLanding.Unsent + + else -> when (restFailureLanding(error)) { + RestFailureLanding.Queue -> RestAttemptLanding.Unsent + RestFailureLanding.Rollback -> RestAttemptLanding.Errored(error) + } + } +} /** * 回落 REST 失败之后该去哪(**纯函数**):进队列,还是走今天那条回滚 + 文案。 @@ -152,7 +245,8 @@ fun reachabilityOf(probed: Result): Reachability = * 令牌失效)。盲目重发换不来不同结果,而且把「服务端拒绝」说成「还没连上」是假话; * - [CyreneError.Local] 里的配置与协议族(`Tls` / `CleartextBlocked` / `InvalidUrl` / `Parse` / * `Unknown`):**等下去不会变好**,它们要的是用户改设置或换地址,回滚时那句 `userMessage` - * 正是他需要看到的东西。这几档在回落这条路上本来就极少出现——连不上的话探测先就拦下了。 + * 正是他需要看到的东西。这几档在回落这条路上本来就极少出现——完全无网时 client 上的短连接 + * 超时([top.yeij.cyrene.data.api.CONNECT_TIMEOUT_MILLIS])先就把它拦下了。 * * 用 `when` 穷举:错误模型将来多一档时,这里必须有人做决定,编不出档位就不该静默继承前一档。 */ diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendQueue.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendQueue.kt index c93b033..99cb55f 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendQueue.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/SendQueue.kt @@ -53,8 +53,9 @@ enum class WsSendOutcome { /** * 未就绪 ⇒ 这条**不是失败**。W41 起它不再直接等于「进队列」:调用方(`ChatViewModel.sendThroughLadder`) - * 会先花一次短探测问后端在不在,通就**回落 REST** 把这条发出去,不通才留在待发态、等连接恢复后 - * 按发送顺序补发。无论哪一种,**都不回滚、不报错误横幅**。 + * 接着往下判一次——**直接试 REST** 把这条发出去(W45 起不再先探连通性,理由见 + * [top.yeij.cyrene.ui.chat.sendRouteFor]),[WsState.Offline] 那一档与试不成的情况才留在待发态、 + * 等连接恢复后按发送顺序补发。无论哪一种,**都不回滚、不报错误横幅**。 */ Queued, @@ -84,8 +85,8 @@ enum class WsSendOutcome { * 3. 其余四档([WsState.Idle] / [WsState.Connecting] / [WsState.Reconnecting] / [WsState.Offline]) * ⇒ [WsSendOutcome.Queued]。`ChatSocket.send` 的**第一行**就按连接状态挡掉,帧连序列化都没做、 * 服务端一个字节都收不到 ⇒ 这不是「发送失败」,是**还没上路**:既不回滚,也不进 `error` 横幅。 - * (W41 起这一档由 `sendThroughLadder` 接着往下判:能回落 REST 发就发,发不了才真进队列。 - * 本函数只回答「帧交出去了没有、连接是不是好的」,不掺探测那一层——那层要发请求,不能是纯函数。) + * (W41 起这一档由 `sendThroughLadder` 接着往下判:能直接试 REST 发就发,发不了才真进队列。 + * 本函数只回答「帧交出去了没有、连接是不是好的」,不掺那一次尝试——它要发请求,不能是纯函数。) * * ## 为什么 Offline 也进队列(而不像告知那样另分一档) * 主人的原话是「未连接就进队列」,四档一视同仁:[WsState.Offline] 只是**不会自己变好**, diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/api/ProbeClientTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/api/ProbeClientTest.kt deleted file mode 100644 index af7e22f..0000000 --- a/app/src/test/kotlin/top/yeij/cyrene/data/api/ProbeClientTest.kt +++ /dev/null @@ -1,200 +0,0 @@ -package top.yeij.cyrene.data.api - -import com.sun.net.httpserver.HttpExchange -import com.sun.net.httpserver.HttpServer -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test -import top.yeij.cyrene.core.error.CyreneError -import top.yeij.cyrene.data.repository.ConnectivityRepository -import top.yeij.cyrene.ui.chat.Reachability -import top.yeij.cyrene.ui.chat.reachabilityOf -import java.net.InetSocketAddress -import java.net.ServerSocket -import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.Executors - -/** - * W41 连通性探测那条链的**运行时接线**:`ApiClientFactory.createProbe` → [HealthApi] → - * `ConnectivityRepository.probe` → `reachabilityOf`。 - * - * 端到端跑**真 OkHttp + 真 Retrofit + 真 HTTP 服务端**(JDK 自带的 `com.sun.net.httpserver`, - * 与 [top.yeij.cyrene.data.repository.ChatRepositoryTest] 同一套做法,不引任何新依赖)。 - * - * ## 它为什么存在 - * W41 的真机回归里,`GET /api/v1/health` 在代理日志里**一条都没有**:WS 断(`Reconnecting`)、 - * HTTP 通(sessions / messages / enums / personas 全 200),这条阶梯却选了队列而不是回落 REST。 - * 那一轮只验过**纯判据**(`sendRouteFor` / `reachabilityOf` 是纯函数,全绿), - * 「这条链真能发出请求吗」**一次都没被跑过**——README 里那句「纯判据全绿、接线却断了」说的就是这件事。 - * 本文件补上这一段:**探测必须真的在 socket 上留下一个 `GET /api/v1/health`**,而不只是判据算对。 - * - * ## 它钉住的四条事实(每一条都是判据,不是描述) - * 1. 请求**真的发出去**,且落到 `GET api/v1/health`(baseUrl 是建 client 时按当前服务器 - * 地址现给的,不是空)。 - * 2. 探测**不带令牌**(`Authorization` 缺席;该端点不鉴权,带一个过期令牌去问会把「后端好好的」 - * 读成不通)——这是 [ApiClientFactory.createProbe] 与通用 [ApiClientFactory.create] 的**唯一** - * 语义差别,必须逐字钉住。 - * 3. `200 + {"data":{...}}` ⇒ `probe()` 回 `Result.success(true)` ⇒ [Reachability.Reachable]; - * 非 2xx 与「连不上」⇒ `Result.failure` ⇒ [Reachability.Unreachable](fail-closed)。 - * 4. 服务端**接了连接却不回话**时,探测在 [PROBE_TIMEOUT_MILLIS] 量级返回失败——它是一道 - * **整通上限**(`callTimeout`),绝不会把用户的发送挂在一次等不到头的探测上。 - */ -class ProbeClientTest { - - private val backend = FakeHealthBackend() - - @After - fun tearDown() { - backend.close() - } - - private fun healthApi(baseUrl: String = backend.baseUrl) = - ApiClientFactory.createProbe(baseUrl, HealthApi::class.java) - - @Test - fun `the probe really puts a GET api v1 health on the wire`() = runBlocking { - backend.reply(200, """{"data":{"status":"ok"}}""") - - healthApi().health() - - // 请求行不含 HTTP 版本(HttpExchange 只给 method + URI),判据是「方法 + 路径」。 - val line = backend.requestLines.single() - assertEquals("GET /api/v1/health", line) - } - - @Test - fun `the probe carries no token because the endpoint is public`() = runBlocking { - backend.reply(200, """{"data":{"status":"ok"}}""") - - healthApi().health() - - assertNull(backend.headers.single()["Authorization"]) - } - - @Test - fun `a 200 with data is reachable`() = runBlocking { - backend.reply(200, """{"data":{"status":"ok"}}""") - - val probed = ConnectivityRepository(healthApi()).probe() - - assertEquals(Result.success(true), probed) - assertEquals(Reachability.Reachable, reachabilityOf(probed)) - } - - @Test - fun `a non 2xx is unreachable`() = runBlocking { - backend.reply(503, """{"error":{"code":"unavailable","message":"down"}}""") - - val probed = ConnectivityRepository(healthApi()).probe() - - assertTrue("非 2xx 该是失败,实际 $probed", probed.isFailure) - assertEquals(Reachability.Unreachable, reachabilityOf(probed)) - } - - @Test - fun `a 2xx without data is unreachable rather than a false reachable`() = runBlocking { - // 回执解析不出来 = 「后端没回我一句我能读懂的话」⇒ fail-closed 到不通。 - backend.reply(200, "{}") - - val probed = ConnectivityRepository(healthApi()).probe() - - assertEquals( - CyreneError.Local(CyreneError.LocalKind.Parse), - (probed.exceptionOrNull() as CyreneException).error, - ) - assertEquals(Reachability.Unreachable, reachabilityOf(probed)) - } - - @Test - fun `a server that accepts but never answers trips the call timeout`() = runBlocking { - // 接住连接、一个字都不回:模拟「半死的代理 / 黑洞服务端」。 - // 判据有两头——**必须失败**(fail-closed),且**在 callTimeout 量级返回** - // (它是整通上限;绝不能退化到共用客户端那条 60s 读超时上,那会把用户的发送挂住)。 - BlackHole().use { hole -> - val started = System.currentTimeMillis() - - val probed = ConnectivityRepository( - ApiClientFactory.createProbe(hole.baseUrl, HealthApi::class.java), - ).probe() - - val elapsed = System.currentTimeMillis() - started - assertTrue("该失败,实际 $probed", probed.isFailure) - assertEquals(Reachability.Unreachable, reachabilityOf(probed)) - assertTrue( - "该在探测上限附近返回,实际 ${elapsed}ms", - elapsed >= PROBE_TIMEOUT_MILLIS - 500, - ) - assertTrue( - "绝不该等到共用客户端那条 60s 读超时,实际 ${elapsed}ms", - elapsed < 15_000, - ) - } - } - - /** 照着 `reply(...)` 回话的假健康端点:记下**请求行原文**与请求头,绝不校验鉴权。 */ - private inner class FakeHealthBackend : AutoCloseable { - - private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply { - executor = Executors.newFixedThreadPool(2) - createContext("/api/v1/health", ::handle) - start() - } - - val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/" - - val requestLines = CopyOnWriteArrayList() - val headers = CopyOnWriteArrayList>() - - @Volatile - private var status = 200 - - @Volatile - private var body = "{}" - - fun reply(status: Int, body: String) { - this.status = status - this.body = body - } - - private fun handle(exchange: HttpExchange) { - requestLines += "${exchange.requestMethod} ${exchange.requestURI}" - headers += exchange.requestHeaders.entries.associate { it.key to it.value.joinToString(", ") } - val payload = body.toByteArray(Charsets.UTF_8) - exchange.responseHeaders.add("Content-Type", "application/json") - exchange.sendResponseHeaders(status, payload.size.toLong()) - exchange.responseBody.use { it.write(payload) } - } - - override fun close() { - server.stop(0) - } - } - - /** 只接连接、不回话的服务端(`callTimeout` 的判据用它)。 */ - private class BlackHole : AutoCloseable { - - private val server = ServerSocket(0) - private val held = CopyOnWriteArrayList() - - /** 收连接的后台线程:`accept` 之后什么都不做,把连接攥住。 */ - private val accepter = Thread { - runCatching { - while (!server.isClosed) held += server.accept() - } - }.apply { - isDaemon = true - start() - } - - val baseUrl: String get() = "http://127.0.0.1:${server.localPort}/" - - override fun close() { - runCatching { server.close() } - accepter.interrupt() - held.forEach { runCatching { it.close() } } - } - } -} diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/api/RestAttemptClientTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/api/RestAttemptClientTest.kt new file mode 100644 index 0000000..c2ddca2 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/api/RestAttemptClientTest.kt @@ -0,0 +1,210 @@ +package top.yeij.cyrene.data.api + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import top.yeij.cyrene.data.api.dto.ChatRequest +import top.yeij.cyrene.data.repository.ChatAck +import top.yeij.cyrene.data.repository.ChatRepository +import top.yeij.cyrene.ui.chat.REST_FALLBACK_TIMEOUT_MILLIS +import top.yeij.cyrene.ui.chat.RestAttemptLanding +import top.yeij.cyrene.ui.chat.restAttemptLanding +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.Executors + +/** + * 「WS 发不出去就直接试 REST」这条路的**运行时接线**:`ApiClientFactory.create`(主 REST client)→ + * [ChatApi] → [ChatRepository.send] → [restAttemptLanding]。 + * + * 端到端跑**真 OkHttp + 真 Retrofit + 真 HTTP 服务端**(JDK 自带的 `com.sun.net.httpserver`,与 + * [top.yeij.cyrene.data.repository.ChatRepositoryTest] 同一套做法,不引任何新依赖)。 + * + * ## 它为什么存在(W45 接下 W41 的位置) + * W41 那道探测的运行时接线由 `ProbeClientTest` 钉住,本轮连探测一起删了。但那个文件里有一条教训 + * 是**和方法无关**的:真机回归里「请求到底有没有写出去」曾经无人可证(代理日志一条都没有,而 + * 「压根没发」与「发出去之前就死了」在外面完全同形)。所以新判据同样要有一条**在真 socket 上** + * 的证据,只是对象换成了真正的那次 `POST /api/v1/chat`: + * + * ## 它钉住的五条事实(每一条都是判据,不是描述) + * 1. 这次尝试**真的写出去**了,落到 `POST api/v1/chat`(用**主 REST client**,不是另 + * 建一个探针 client)。 + * 2. 请求体里带的 `clientMsgId` **就是那条乐观气泡的 `localId`**:这是「试败进队列后重发也只落 + * 一条」的全部依据(服务端按 `(session_id, client_msg_id)` 仲裁),也是「WS 那帧没交出去、 + * REST 这条带上同一个键」这件事唯一的可验证形态。 + * 3. 它**带着令牌**(`Authorization: Bearer …`)——与 W41 那个不鉴权的探针**恰好相反**:这条走的 + * 是真正的业务端点(带 `X-API-Version` 与 401 续期),不是一次健康检查。 + * 4. 服务端收下(`200 {stream:true}` / `202 {queued:true}` / `200 {duplicate:true}`)⇒ + * [RestAttemptLanding.Accepted](**不进队列**);服务端拒绝(`409 chat_busy` 等)⇒ + * [RestAttemptLanding.Refused](回滚)。 + * 5. 连不上(对端不在听)⇒ 失败,且在**连接超时量级**内返回,落到 [RestAttemptLanding.Unsent] + * (进队列)——绝不会把用户的发送挂在一整通上限上。 + */ +class RestAttemptClientTest { + + private val backend = FakeChatBackend() + + @After + fun tearDown() { + backend.close() + } + + /** 与生产同形:主 REST client(带令牌与 401 策略),只是 baseUrl 指向本地假端点。 */ + private fun repository(baseUrl: String = backend.baseUrl) = ChatRepository( + ApiClientFactory.create( + baseUrl = baseUrl, + service = ChatApi::class.java, + tokenProvider = { TOKEN }, + ), + ) + + /** 与生产同形的一次尝试:带整通上限的、原样交给 [restAttemptLanding] 的那个 `Result`。 */ + private suspend fun attempt( + repository: ChatRepository, + clientMsgId: String = CLIENT_MSG_ID, + ): Result? = withTimeoutOrNull(REST_FALLBACK_TIMEOUT_MILLIS) { + repository.send( + ChatRequest( + sessionId = SESSION_ID, + text = "你好", + deviceId = "voiceassist:cyrene:v1", + stream = true, + clientMsgId = clientMsgId, + ), + ) + } + + @Test + fun `the attempt really puts a POST api v1 chat on the wire`() = runBlocking { + backend.reply(200, """{"data":{"stream":true}}""") + + attempt(repository()) + + // 请求行不含 HTTP 版本(HttpExchange 只给 method + URI),判据是「方法 + 路径」。 + assertEquals("POST /api/v1/chat", backend.requestLines.single()) + } + + @Test + fun `the attempt carries the very same client message id as the ws frame had`() = runBlocking { + backend.reply(200, """{"data":{"stream":true}}""") + + attempt(repository(), clientMsgId = "local-42") + + val sent = ApiClientFactory.json.decodeFromString(ChatRequest.serializer(), backend.bodies.single()) + // 「发过两次也只落一条」的全部依据:REST 这条**必须**带同一条乐观气泡的键。 + assertEquals("local-42", sent.clientMsgId) + assertEquals(SESSION_ID, sent.sessionId) + // `stream:true` 也是必须的:`stream:false` 是同步的,服务端没有写超时,客户端读超时会先超时。 + assertEquals(true, sent.stream) + } + + @Test + fun `the attempt carries the token because this is the real chat endpoint`() = runBlocking { + backend.reply(200, """{"data":{"stream":true}}""") + + attempt(repository()) + + val headers = backend.headers.single() + assertEquals("Bearer $TOKEN", headers["authorization"]) + assertEquals("1", headers["x-api-version"]) + } + + @Test + fun `acks that mean the server took the message land as done and never queue`() = runBlocking { + // 三种「服务端已经收下」的回执形状(契约 v0.56 的响应面),逐个走一遍真 HTTP。 + val accepted = listOf( + 200 to """{"data":{"stream":true}}""", + 202 to """{"data":{"stream":true,"queued":true,"queueDepth":1}}""", + 200 to """{"data":{"duplicate":true,"messageId":"m-9","sessionId":"web"}}""", + ) + accepted.forEach { (status, body) -> + backend.reply(status, body) + + val landing = restAttemptLanding(attempt(repository())) + + assertTrue("$status $body 该是「服务端收下了」,实际 $landing", landing is RestAttemptLanding.Accepted) + } + } + + @Test + fun `a refusal from the server lands as a rollback instead of a hidden retry`() = runBlocking { + backend.reply(409, """{"error":{"code":"chat_busy","message":"会话正忙,待处理消息已满"}}""") + + val landing = restAttemptLanding(attempt(repository())) + + // 服务端**回了话**:这条没被受理、没落库 ⇒ 回滚 + 文案,既不进队列也不偷偷重发。 + assertEquals(RestAttemptLanding.Refused(ChatAck.QueueFull), landing) + } + + @Test + fun `an endpoint that is not listening fails fast and lands in the queue`() = runBlocking { + // 一个没人听的端口(拿一个空闲端口号之后立刻放掉):连不上 ⇒ 失败。 + // 判据两头——**必须落到队列**(内容一个字节不丢),且**在短连接超时量级内返回** + // (绝不能退化成整通上限,那会把用户的发送挂住)。 + val deadPort = ServerSocket(0).use { it.localPort } + val started = System.currentTimeMillis() + + val landing = restAttemptLanding(attempt(repository("http://127.0.0.1:$deadPort/"))) + + val elapsed = System.currentTimeMillis() - started + assertEquals(RestAttemptLanding.Unsent, landing) + assertTrue( + "连不上该很快返回(连接超时量级),实际 ${elapsed}ms", + elapsed < CONNECT_TIMEOUT_MILLIS + 3_000L, + ) + } + + /** 照着 `reply(...)` 回话的假 chat 端点:记下**请求行原文**、请求头原文与请求体原文。 */ + private inner class FakeChatBackend : AutoCloseable { + + private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply { + executor = Executors.newFixedThreadPool(2) + createContext("/api/v1/chat", ::handle) + start() + } + + val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/" + + val requestLines = CopyOnWriteArrayList() + /** 请求头按**小写键**记录:JDK 的 `Headers` 会把键规范化成 `X-Api-Version` 这种形状。 */ + val headers = CopyOnWriteArrayList>() + val bodies = CopyOnWriteArrayList() + + @Volatile + private var status = 200 + + @Volatile + private var body = "{}" + + fun reply(status: Int, body: String) { + this.status = status + this.body = body + } + + private fun handle(exchange: HttpExchange) { + requestLines += "${exchange.requestMethod} ${exchange.requestURI}" + headers += exchange.requestHeaders.entries.associate { it.key.lowercase() to it.value.joinToString(", ") } + bodies += String(exchange.requestBody.readBytes(), Charsets.UTF_8) + val payload = body.toByteArray(Charsets.UTF_8) + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(status, payload.size.toLong()) + exchange.responseBody.use { it.write(payload) } + } + + override fun close() { + server.stop(0) + } + } + + private companion object { + const val SESSION_ID = "web" + const val CLIENT_MSG_ID = "local-1" + const val TOKEN = "test-token" + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendFallbackTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendFallbackTest.kt index e29f4d6..46bb6fe 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendFallbackTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/SendFallbackTest.kt @@ -1,193 +1,203 @@ package top.yeij.cyrene.ui.chat -import java.net.ConnectException -import java.net.SocketTimeoutException -import java.net.UnknownHostException import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Test import top.yeij.cyrene.core.error.CyreneError -import top.yeij.cyrene.data.api.PROBE_TIMEOUT_MILLIS +import top.yeij.cyrene.data.api.CONNECT_TIMEOUT_MILLIS +import top.yeij.cyrene.data.api.CyreneException +import top.yeij.cyrene.data.repository.ChatAck import top.yeij.cyrene.data.ws.WsState import top.yeij.cyrene.domain.chat.ChatSendRouting import top.yeij.cyrene.domain.chat.DraftDocument import top.yeij.cyrene.domain.chat.SendChannel /** - * 「未连接时先探测、能发就发,不通才排队」这条三级阶梯的判据 - * ([sendRouteFor] / [reachabilityOf] / [restFailureLanding])。 + * 「WS 发不出去就**直接试 REST**」这套判据 + * ([sendRouteFor] / [restAttemptLanding] / [restFailureLanding] / [ChatAck.isAccepted])。 * - * ## 背景:主人 2026-10-05 的第二次定案 - * W40 定的是「未连接就进队列」([SendQueueTest] 那次),它解决的是**内容不被退回给用户**。 - * 这一轮的追问是「**未连接状态你就不能拿 httpAPI 尝试吗?**」,并明确为「可以走 health 端点验证 - * 连通性」。理由是网关只有一条:`/ws` 与 `POST /api/v1/chat` 是同一个 `:8080` 上的两条车道, - * 握手被挡(代理不转发 Upgrade、中间设备掐长连接)时 REST 往往是通的——直接排队等于把 - * 「WS 这条车道暂时不通」当成「后端不可用」,让用户白等一次重连。 + * ## 背景:W45 把探测换成了直接尝试(主人的定案) + * W41 的阶梯是「WS 未就绪 ⇒ 先探 `GET /api/v1/health` ⇒ 通才回落 REST」。真机回归 + * (`devtools/ws_block_proxy.py` 只掐 `/ws/chat`、HTTP 全放行)发现消息进了队列而不是回落 REST, + * 而 `ProbeClientTest` 证明那条链在 JVM 上是通的、`Unreachable` 生在**连接之前**,三种成因在外 + * 同形 ⇒ 无法定论。判据与论证见 [sendRouteFor]。 * - * ## 本测试钉住五件事 - * 1. **优先级**([sendRouteFor]):帧交出去了 ⇒ 走 WS(连接那一格不再参与判断);未就绪 + 探测通 - * ⇒ 走 REST 回落;未就绪 + 探测不通 / 超时 / 抛异常 / 没探过 ⇒ 进队列。 - * 2. **fail-closed**([reachabilityOf]):只有「探到 `true`」才算通,其余一切(含超时与异常) - * 都归「不通」——探测这一层永远不因为拿不准而放行一次真发送。 - * 3. **探测必须短**:[PROBE_TIMEOUT_MILLIS] 落在 2~3 秒里(它直接加在用户的发送动作上)。 - * 4. **Offline 不探测**:那一档是「会话已知是死的」,健康端点不鉴权必然回 200,探了只会把 - * W40 的「已排队不会丢」换成一次回滚 + 一句误导人的鉴权错误。 - * 5. **回落失败后的落点**([restFailureLanding]):网络类失败(含超时)⇒ 进队列;服务端给过 - * 裁决的(拒绝 / 401)与配置类错误 ⇒ 今天那条回滚 + 文案。 + * 现在的判据里**没有任何探测输入**:路由只看「帧交出去了没有 + 这一刻的连接状态」两个事实, + * REST 那一次的产物由 [restAttemptLanding] 判落点。 + * + * ## 本测试钉住六件事 + * 1. **优先级**([sendRouteFor]):帧交出去了 ⇒ 走 WS(连接状态不再参与);未就绪且不是 + * [WsState.Offline] ⇒ 直接试 REST;[WsState.Offline] ⇒ 进队列、**一个请求都不发**。 + * 2. **收下了就不进队列**:受理 / 续接排队 / 幂等重复([ChatAck.isAccepted] 那一族)⇒ + * [RestAttemptLanding.Accepted](收下还排队会白补发一次,界面上那句「排队中」也成了假话)。 + * 3. **fail-closed**:没等到裁决(整通上限到点 / 异常映射不出来)与网络类失败 ⇒ + * [RestAttemptLanding.Unsent]——**绝不许**把没上路的说成发出去了。 + * 4. **服务端给过裁决 ⇒ 回滚**:拒绝的回执(`409` / `500` / `503` / `400`)与 401 / 配置类异常。 + * 5. **两个超时**:连接超时落在 2~3 秒(无网时的等待上界),整通上限另设且不许拉长。 + * 6. **这条阶梯到不了「REST 专属」的消息**(有图 / 有附件一律在进入阶梯之前就被判成 REST)。 */ class SendFallbackTest { // —— 一、优先级:这一刻该走哪条路 —— @Test - fun `a frame handed to the socket walks the ws path whatever else says`() { - // `sendChat` 回 true 就是「帧交给 socket 了」:连接状态那一格与探测结论都不再参与判断 - // (`sendChat` 判的就是它自己的状态)。这一档是**成功路径**,W41 一个字都不许动它—— - // 所以这里连探测结论都不给它:`null` 也得是 Ws。 + fun `a frame handed to the socket walks the ws path whatever the connection says`() { + // `sendChat` 回 true 就是「帧交给 socket 了」:连接状态那一格不再参与判断(`sendChat` 判的 + // 就是它自己的状态)。这一档是**成功路径**,W41 与 W45 一个字都没动它——所以这里把五档 + // 连接状态逐个钉一遍。 WsState.entries.forEach { connection -> - listOf(null, Reachability.Reachable, Reachability.Unreachable).forEach { reachability -> - assertEquals( - "$connection 上帧已经交出去了,不该改走别的路", - SendRoute.Ws, - sendRouteFor(wsDispatched = true, connection = connection, reachability = reachability), - ) - } + assertEquals( + "$connection 上帧已经交出去了,不该改走别的路", + SendRoute.Ws, + sendRouteFor(wsDispatched = true, connection = connection), + ) } } @Test - fun `not connected but reachable falls back to rest`() { - // WS 没把帧交出去,而探测说后端这一刻在 ⇒ 用既有的 POST /api/v1/chat 把这条发出去。 - // 三档「未就绪」一视同仁:Idle / Connecting / Reconnecting 都只是「还没连上」, - // 与「后端不可达」根本不是一件事。 + fun `a not yet connected state tries rest directly instead of probing`() { + // 三档「还没连上」一视同仁:Idle / Connecting / Reconnecting 都只是「还没连上」,与 + // 「后端不可达」根本不是一件事。W45 起这里**直接试**——真正的 `POST /api/v1/chat` 就是那次 + // 检验(试成了消息还顺手发出去了),不再先花一个请求问「后端在不在」。 listOf(WsState.Idle, WsState.Connecting, WsState.Reconnecting).forEach { connection -> assertEquals( - "$connection 上探测通了,该回落 REST", + "$connection 上该直接试 REST", SendRoute.Rest, - sendRouteFor(wsDispatched = false, connection = connection, reachability = Reachability.Reachable), + sendRouteFor(wsDispatched = false, connection = connection), ) } } @Test - fun `not connected and unreachable queues`() { - listOf(WsState.Idle, WsState.Connecting, WsState.Reconnecting).forEach { connection -> - assertEquals( - "$connection 上探测不通,该进队列", - SendRoute.Queue, - sendRouteFor(wsDispatched = false, connection = connection, reachability = Reachability.Unreachable), - ) - } - } - - @Test - fun `no probe result at all means queue, never a hopeful rest send`() { - // `null` = 还没探过(或判据被改坏没喂结论)。没有证据说能发,就不发: - // 这一档必须与「探测不通」同归一处,否则「拿不准」会变成一次注定失败的真发送。 - listOf(WsState.Idle, WsState.Connecting, WsState.Reconnecting, WsState.Offline).forEach { connection -> - assertEquals( - "$connection 上还没有探测结论,只许进队列", - SendRoute.Queue, - sendRouteFor(wsDispatched = false, connection = connection, reachability = null), - ) - } - } - - // —— 二、探测的结论怎么算:fail-closed —— - - @Test - fun `only a confirmed answer counts as reachable`() { - assertEquals(Reachability.Reachable, reachabilityOf(Result.success(true))) - // 后端回了话但非 2xx / 回执解析不出来:仓库层折成 `false` 或 `failure`,都不是通。 - assertEquals(Reachability.Unreachable, reachabilityOf(Result.success(false))) - } - - @Test - fun `a timing out probe is not reachable`() { - // 硬要求:探测超时一律按「不通」处理(fail-closed 到队列)——**绝不许**把用户的发送 - // 卡在一个等不到头的探测上,也绝不许因为「说不定通」就先发一次 REST。 - assertEquals( - Reachability.Unreachable, - reachabilityOf(Result.failure(SocketTimeoutException("call timeout"))), - ) - } - - @Test - fun `a probe that throws is not reachable, whatever it threw`() { - // 连不上 / DNS / 别的任何异常:同一档。探测这一层**永远不抛异常给调用方**—— - // 它要回答的是一个 yes/no,抛出去只会让「这条消息去哪」这件事没有答案。 - val thrown = listOf( - ConnectException("connection refused"), - UnknownHostException("cyrene.invalid"), - IllegalStateException("boom"), - ) - thrown.forEach { e -> - assertEquals("$e 不该被当成通", Reachability.Unreachable, reachabilityOf(Result.failure(e))) - } - } - - @Test - fun `an unset result is not reachable either`() { - // `Result` 里没有值(既没成功也没给异常)也是一种「没拿到答案」⇒ 不通。 - assertEquals(Reachability.Unreachable, reachabilityOf(Result.failure(RuntimeException()))) - } - - @Test - fun `the probe timeout is short enough to sit inside a send`() { - // 硬要求:探测必须有 2~3 秒量级的短超时。它直接加在用户的发送动作上(气泡已经上屏、 - // 用户等着这条消息的结论),所以既不能长到像卡死,也不能短到正常网络下误判成不通。 - assertTrue( - "探测超时 $PROBE_TIMEOUT_MILLIS ms 落在 2~3 秒之外", - PROBE_TIMEOUT_MILLIS in 2_000L..3_000L, - ) - // 回落 REST 那一次投递另有一道上限,且必须比探测宽(它是真请求,不是探针): - // 两道合起来才是「没网时用户最多等多久」的上界。 - assertTrue( - "回落 REST 的上限该比探测宽裕", - REST_FALLBACK_TIMEOUT_MILLIS > PROBE_TIMEOUT_MILLIS, - ) - } - - // —— 三、Offline 那一档:不探测,直接排队 —— - - @Test - fun `offline queues without probing, because the session is already known dead`() { + fun `offline queues without trying rest because the session is already known dead`() { // Offline 的语义是「握手 401 且续期也失败」(终态、不自动重连)⇒ 会话已知是死的。 - // 健康端点**不鉴权**(`Cyrene_v2/internal/services/gateway/routes.go:51` 挂在公开路由里), - // 探它必然回 200 ⇒ 判成 Reachable ⇒ 发一次注定 401 的 REST ⇒ 把 W40 那句 - // 「已排队不会丢」换成一次回滚 + 一句会误导人的鉴权错误。所以这一档**根本不探**。 - listOf(null, Reachability.Reachable, Reachability.Unreachable).forEach { reachability -> - assertEquals( - "Offline 上不该因为探测通就去发 REST", - SendRoute.Queue, - sendRouteFor(wsDispatched = false, connection = WsState.Offline, reachability = reachability), - ) - } + // 这一档去试 REST 只会拿到 401、落到「回滚 + 一句会误导人的鉴权错误」,把 W40 那句 + // 「已排队不会丢」换成更差的结果。所以这一档**一次请求都不发**。 + // + // W41 也判这一档进队列(它当时的理由是「不鉴权的健康端点必然回 200 ⇒ 假通」);探测没了 + // 之后这个结论一字不用改——带令牌的 chat 端点回 401 是**真不通**,两条路指向同一件事。 + assertEquals(SendRoute.Queue, sendRouteFor(wsDispatched = false, connection = WsState.Offline)) } @Test - fun `offline is the only state that skips the probe`() { - // 三档「还没连上」都该走到探测那一步(判据只认 Connected 一个值,也排除 Offline 一个值): - // 将来连接状态多一档时,这一条会逼着有人来做决定,而不是静默继承。 + fun `offline is the only not connected state that skips the rest attempt`() { + // 判据只认 Connected 一个值,也只把 Offline 排除掉一个值:将来连接状态多一档时,这一条会 + // 逼着有人来做决定,而不是静默继承。 WsState.entries .filter { it != WsState.Connected && it != WsState.Offline } .forEach { connection -> assertEquals( - "$connection 上该允许回落 REST", + "$connection 上该允许直接试 REST", SendRoute.Rest, - sendRouteFor(wsDispatched = false, connection = connection, reachability = Reachability.Reachable), + sendRouteFor(wsDispatched = false, connection = connection), ) } } - // —— 四、回落 REST 失败之后去哪 —— + @Test + fun `the whole routing table has two inputs and no probe among them`() { + // 判据的全部输入就是这两个事实(**没有探测结论**——那是 W45 删掉的东西,这条断言连同 + // [sendRouteFor] 的签名一起把这件事钉死:多喂一个探测结论就编译不过)。每一格都有明确 + // 结论,没有第三档可能的输入。 + val table = listOf( + Triple(true, WsState.Idle, SendRoute.Ws), + Triple(true, WsState.Connecting, SendRoute.Ws), + Triple(true, WsState.Reconnecting, SendRoute.Ws), + Triple(true, WsState.Connected, SendRoute.Ws), + Triple(true, WsState.Offline, SendRoute.Ws), + Triple(false, WsState.Idle, SendRoute.Rest), + Triple(false, WsState.Connecting, SendRoute.Rest), + Triple(false, WsState.Reconnecting, SendRoute.Rest), + // 这一格从阶梯上**到不了**:帧没交出去而连接就绪时 [wsSendOutcome] 判的是 + // `Failed`(回滚),sendRouteFor 根本不会被问到。写在这里是为了这张表是**全的** + // (函数对五档连接状态都有定义),不是因为它是可达的一格。 + Triple(false, WsState.Connected, SendRoute.Rest), + Triple(false, WsState.Offline, SendRoute.Queue), + ) + table.forEach { (dispatched, connection, expected) -> + assertEquals( + "dispatched=$dispatched / $connection 该走 $expected", + expected, + sendRouteFor(wsDispatched = dispatched, connection = connection), + ) + } + } + + // —— 二、直接试 REST 的落点 —— @Test - fun `network failures on the fallback queue instead of rolling back`() { + fun `a server that took the message means done and never a queue entry`() { + // 三种回执都是「服务端已经收下这条」:`200 {stream:true}`(起回合)、 + // `202 {queued:true}`(忙时续接排队,已落库)、`200 {duplicate:true}`(幂等键早就收过)。 + // ⇒ 了结,**不进队列**:收下了还排队会白补发一次(幂等键兜得住,但那是浪费,界面上那句 + // 「排队中 · 还没连上」也成了假话)。 + val accepted = listOf( + ChatAck.Accepted, + ChatAck.Queued(queueDepth = 2), + ChatAck.Duplicate(messageId = "m-1", sessionId = "web"), + ) + accepted.forEach { ack -> + assertEquals( + "$ack 是服务端收下了,该是 Done", + RestAttemptLanding.Accepted(ack), + restAttemptLanding(Result.success(ack)), + ) + } + } + + @Test + fun `a rejected ack rolls back instead of being retried behind the user's back`() { + // 服务端**回了话**就是它的裁决:`409 chat_busy`(队列满)/ `500 chat_unavailable` / + // `503 chat_dispatch_failed` / `400` 别的明确拒绝。盲目重发换不来不同结果,而且把 + // 「服务端拒绝」说成「还没连上」是假话 ⇒ 回滚 + 各自的文案(文案在 onAck 那六支里)。 + val refused = listOf( + ChatAck.QueueFull, + ChatAck.Unavailable("chat_unavailable", "chat service is not configured"), + ChatAck.Rejected("bad_request", "文本、image 或 documents 至少需要一项"), + ) + refused.forEach { ack -> + assertEquals( + "$ack 是服务端的裁决,该回滚", + RestAttemptLanding.Refused(ack), + restAttemptLanding(Result.success(ack)), + ) + } + } + + @Test + fun `an unauthorized or a configuration error rolls back because waiting will not fix it`() { + // 401 与配置 / 协议族走的是**异常**那一支(`ChatRepository` 把 401 折成 failure),同样回滚: + // 它们要的是用户重新登录 / 改设置 / 换地址,等下去不会变好;回滚时那句 userMessage 正是他 + // 需要看到的东西(把「令牌失效」说成「还没连上」会让他干等)。 + val unauthorized = CyreneError.Unauthorized(code = "invalid_token", message = "") + assertEquals( + RestAttemptLanding.Errored(unauthorized), + restAttemptLanding(Result.failure(CyreneException(unauthorized))), + ) + + val config = listOf( + CyreneError.LocalKind.Tls, + CyreneError.LocalKind.CleartextBlocked, + CyreneError.LocalKind.InvalidUrl, + CyreneError.LocalKind.Parse, + CyreneError.LocalKind.Unknown, + ) + config.forEach { kind -> + val error = CyreneError.Local(kind) + assertEquals("$kind 等下去不会变好,该回滚", RestFailureLanding.Rollback, restFailureLanding(error)) + assertEquals( + RestAttemptLanding.Errored(error), + restAttemptLanding(Result.failure(CyreneException(error))), + ) + } + } + + @Test + fun `a network failure puts the message in the queue`() { // 这一族都是「请求根本没被服务端读到」:等一下多半会好,而内容一点都不该退给用户。 - // 重发用的是同一个 clientMsgId,服务端按 (session_id, client_msg_id) 仲裁:真重复到达 - // 只回 chat_duplicate / {duplicate:true},不会再落库、不会再起回合(契约 v0.56 §3.13)。 + // 重发用的是**同一个** `clientMsgId`,服务端按 (session_id, client_msg_id) 仲裁:真重复 + // 到达只回 chat_duplicate / {duplicate:true},不会落第二条(契约 v0.56 §3.13)。 val network = listOf( CyreneError.LocalKind.NoNetwork, CyreneError.LocalKind.Dns, @@ -195,29 +205,66 @@ class SendFallbackTest { CyreneError.LocalKind.ConnectFailed, ) network.forEach { kind -> + val error = CyreneError.Local(kind) assertEquals( "$kind 属于网络类失败,该进队列", - RestFailureLanding.Queue, - restFailureLanding(CyreneError.Local(kind)), + RestAttemptLanding.Unsent, + restAttemptLanding(Result.failure(CyreneException(error))), ) + // 同一档在 W41 那套落点判据上也是队列(口径一字未改,[restAttemptLanding] 只是复用它)。 + assertEquals(RestFailureLanding.Queue, restFailureLanding(error)) } } @Test - fun `a fallback that got no verdict at all queues`() { - // `null` = 本端自己设的那道上限到点了(withTimeoutOrNull 回 null)或抛了个映射不出来的 - // 异常:服务端没给过裁决 ⇒ 这条「还没上路」,与网络类失败同一档。 + fun `no verdict at all queues, never a hopeful success`() { + // `null` = 本端自己设的整通上限到点了(withTimeoutOrNull 回 null):服务端没给过裁决。 + // 这条可能已经被服务端收下(只是回执没回来),也可能压根没到——本端分不出来,而**分不出来 + // 就往队列倒**(fail-closed)。这是「一条没上路的消息绝不被当成发出去了」的落点。 + assertEquals(RestAttemptLanding.Unsent, restAttemptLanding(null)) assertEquals(RestFailureLanding.Queue, restFailureLanding(null)) + + // 异常映射不出来(不是 CyreneException)同理:本端说不清服务端有没有收下 ⇒ 队列。 + assertEquals( + RestAttemptLanding.Unsent, + restAttemptLanding(Result.failure(IllegalStateException("boom"))), + ) } @Test - fun `a verdict from the server is never re-sent behind the user's back`() { - // 服务端**回了话**就是它的裁决:拒绝(4xx/5xx 的明确码)与未授权都不该被悄悄重发—— - // 重发换不来不同结果,而且把「服务端拒绝」说成「还没连上」是假话。 + fun `isAccepted covers every ack shape without a default`() { + // 逐一穷举回执面:受理族(起回合 / 续接排队 / 幂等重复)为真,拒绝族(队列满 / 接不住 / + // 明确拒绝)为假。`ChatAck` 将来多一档时这里会编译不过——必须有人来做决定。 + assertTrue(ChatAck.Accepted.isAccepted()) + assertTrue(ChatAck.Queued(queueDepth = 0).isAccepted()) + assertTrue(ChatAck.Duplicate(messageId = null, sessionId = null).isAccepted()) + + assertTrue(!ChatAck.QueueFull.isAccepted()) + assertTrue(!ChatAck.Unavailable("chat_unavailable", "").isAccepted()) + assertTrue(!ChatAck.Rejected("bad_request", "").isAccepted()) + } + + @Test + fun `every local kind is decided, none is inherited by accident`() { + // 穷举 `LocalKind`:要么进队列、要么回滚,**没有第三种默认**。这条断言的价值在错误模型将来 + // 多一档时——那时 `restFailureLanding` 会编译不过(when 不穷举),而不是静默把新档位当成 + // 「网络类」重发出去。 + CyreneError.LocalKind.entries.forEach { kind -> + val landing = restFailureLanding(CyreneError.Local(kind)) + assertTrue( + "$kind 必须被明确分到某一档", + landing == RestFailureLanding.Queue || landing == RestFailureLanding.Rollback, + ) + } assertEquals( - RestFailureLanding.Rollback, - restFailureLanding(CyreneError.Server("bad_request", "invalid text", 400)), + RestFailureLanding.Queue, + restFailureLanding(CyreneError.Local(CyreneError.LocalKind.NoNetwork)), ) + assertNotEquals( + RestFailureLanding.Queue, + restFailureLanding(CyreneError.Local(CyreneError.LocalKind.Tls)), + ) + // 服务端裁决的那两类:也都回滚([CyreneError.Server] 有 code/status,[CyreneError.Unauthorized] 没有)。 assertEquals( RestFailureLanding.Rollback, restFailureLanding(CyreneError.Server("internal", "boom", 500)), @@ -228,58 +275,40 @@ class SendFallbackTest { ) } - @Test - fun `configuration failures roll back because waiting will not fix them`() { - // 配置与协议这一族要的是用户改设置 / 换地址,等下去不会变好;回滚时那句 userMessage - // 正是他需要看到的东西(把「证书不对」说成「还没连上」会让他干等)。 - val config = listOf( - CyreneError.LocalKind.Tls, - CyreneError.LocalKind.CleartextBlocked, - CyreneError.LocalKind.InvalidUrl, - CyreneError.LocalKind.Parse, - CyreneError.LocalKind.Unknown, - ) - config.forEach { kind -> - assertEquals( - "$kind 等下去不会变好,该回滚并说清", - RestFailureLanding.Rollback, - restFailureLanding(CyreneError.Local(kind)), - ) - } - } + // —— 三、两个超时(无网快速进队列的关键) —— @Test - fun `every local kind is decided, none is inherited by accident`() { - // 穷举 `LocalKind`:判据要么进队列、要么回滚,**没有第三种默认**。这条断言的价值在 - // 错误模型将来多一档时——那时 `restFailureLanding` 会编译不过(when 不穷举), - // 而不是静默把新档位当成「网络类」重发出去。 - CyreneError.LocalKind.entries.forEach { kind -> - val landing = restFailureLanding(CyreneError.Local(kind)) - assertTrue( - "$kind 必须被明确分到某一档", - landing == RestFailureLanding.Queue || landing == RestFailureLanding.Rollback, - ) - } - // 队列那一档恰好是「请求没被服务端读到」的网络族;其余一律回滚。 - assertEquals( - RestFailureLanding.Queue, - restFailureLanding(CyreneError.Local(CyreneError.LocalKind.NoNetwork)), + fun `the connect timeout is short enough to sit inside a send`() { + // 硬要求:**完全无网时消息要在 ~3 秒内进队列**。W45 起这条性质由 client 上的**连接超时** + // 承担(不再有探针):连不上就在这个量级内失败 ⇒ 进队列。既不能长到像卡死,也不能短到正常 + // 网络下误连不上。它直接加在用户的发送动作上(气泡已经上屏、用户等着这条消息的结论)。 + assertTrue( + "连接超时 $CONNECT_TIMEOUT_MILLIS ms 落在 2~3 秒之外", + CONNECT_TIMEOUT_MILLIS in 2_000L..3_000L, ) - assertNotEquals( - RestFailureLanding.Queue, - restFailureLanding(CyreneError.Local(CyreneError.LocalKind.Tls)), + + // 第二个超时(整通上限)另设、且**必须比连接超时宽**:它管的是「连上了但一直不回话」那一档 + // (半死的代理、接住连接却不回应的服务端),到点也按网络类失败 ⇒ 进队列。 + assertTrue( + "整通上限该比连接超时宽裕", + REST_FALLBACK_TIMEOUT_MILLIS > CONNECT_TIMEOUT_MILLIS, + ) + // 且**不许拉长**:它是用户按完发送键之后的干等时间上界,W41 定在 10 秒,本值就是 10 秒。 + assertTrue( + "整通上限不许超过 W41 定的 10 秒,实际 $REST_FALLBACK_TIMEOUT_MILLIS ms", + REST_FALLBACK_TIMEOUT_MILLIS <= 10_000L, ) } - // —— 五、这条回落到不了「REST 专属」的消息 —— + // —— 四、这条阶梯到不了「REST 专属」的消息 —— @Test fun `image and document messages never enter this ladder at all`() { - // 回落 REST 只服务「本该走 WS 的短文本」:有图 / 有附件的消息在**进入阶梯之前**就被 + // 这条阶梯只服务「本该走 WS 的短文本」:有图 / 有附件的消息在**进入阶梯之前**就被 // [ChatSendRouting.channelFor] 判成 Rest,走的是另一条分支(今天的 `sendViaRest` / - // `deliverImage`,行为一个字没变)。所以它们既不会因为「WS 未就绪」而多花一次探测, - // 也不会因为回落失败而进队列——**没有本地 URI 过一阵子还能不能读、图片要不要重编码 - // 这一类新问题**(队列不持久化,真跨了进程 URI 授权就失效)。 + // `deliverImage`,行为一个字没变)。所以它们既不会因为「WS 未就绪」而被试一次 REST, + // 也不会因为试败而进队列——**没有本地 URI 过一阵子还能不能读、图片要不要重编码这一类 + // 新问题**(队列不持久化,真跨了进程 URI 授权就失效)。 // 路由规则本身由 `ChatSendPolicyTest` 逐个钉住,这里只钉「阶梯的入口因此变窄」这件事。 val documents = listOf(DraftDocument("content://doc", "a.txt"))