fix(chat): 分享落地必须等到连接就绪,超时不再等于发送失败

为什么超时不能等于发送失败
WS 还没连上时投递的分享(ACTION_SEND)此前走成了「发送失败,请重试」,内容还从待发队列里
永久消失。来处是「等连接」那句 withTimeoutOrNull 的返回值被丢掉了:超时与成功走同一条下游,
照样 take() 再 sendShared → deliver → ChatSocket.sendChat 回 false ⇒ ChatViewModel 落到
rollback(localId, "发送失败,请重试")。实机取证(2026-10-05 11:56):GET /ws/chat status=200
前后的窗口里投递分享,界面上是失败横幅 + 正文退回输入栏,而后端零请求(POST /api/v1/chat
一条都没有,也没有任何 error 帧)——那条横幅把「一次都没尝试发出去」说成了「发送失败」。
ShareIntake.take() 是原子出队且没有归还路径:那一次「其实一次都没发出去」的失败之后,内容
既不在 holder 里、也不在服务端,只剩回滚进输入栏的那一份(多图分享里除首条之外,连输入栏
都回不去)。重试资格因此一起丢了。所以判据只有一条:没连上就一个字节都别动——不 take()、
不发送、不产生任何 error 横幅。

为什么取走时机必须晚于连接就绪
短文本走 WS chat 帧,而分享冷启动必然撞上握手还没完成;帧发不发得出去完全取决于握手结果。
所以等待里不再有「到点就照发」这一档,只有「连上才发」:连接恢复之后由连接状态自己把这条
落地重新触发一遍。

改法
- 判据抽成纯函数 shareLanding(ui/chat/ShareLanding.kt,可单测):没有待发 ⇒ Nothing;已连接
  ⇒ Send(压过后两条,且返回的是单值动作,所以发送与告知不可能同时发生);未就绪且还没等够
  ⇒ KeepWaiting;未就绪、已等够、还没说过 ⇒ TellWaiting;已经说过 ⇒ KeepWaiting(不刷屏)。
- ChatScreen 的那个 effect 把连接状态并进键,连接后来就绪会自动重跑、自动补发;「不会重复
  发送」仍然由 ShareIntake.take() 的原子性兜住——哪怕某一次拿着已经过期的快照进来,take()
  给出的也只是队列里的下一条或 null。
- 等太久只做一件事:经既有的柔和通道 ChatViewModel.showShareNotice 说一句「还没连上,分享的
  内容会留着,连上后自动发出」(不是带 X 的 error 横幅),同一次等待只说一次。
- SHARE_CONNECT_TIMEOUT_MILLIS 改名 SHARE_WAIT_NOTICE_MILLIS:它不再是发送的时限,只是
  「等多久才值得说那一句」;「等够久」的计时只跟「队列里有没有东西」与「连上了没有」走,
  连接在 Connecting / Reconnecting 之间抖动不重置它。
- 普通发送路径(输入框、图片、附件)一行未动。

测试
- ShareLandingTest(新,7 条):未就绪在任何连接状态下都恒不 Send(不取、不发);未就绪且没
  等够恒 KeepWaiting(也不告知);等够了只说一次、说过了就不再刷屏;已连接恒 Send(压过告知);
  无待发恒 Nothing(任何连接状态下都不做事);文案必须说清「还没连上」与「自动发出」,且不含
  「失败」「重试」。

assembleDebug 与 testDebugUnitTest 全绿(1091 tests / 0 failures / 0 errors)。
This commit is contained in:
AskaEth committed 2026-10-05 13:21:09 +08:00
1 parent 44b58db58a
commit aeb37458e8
4 files changed
+277 -20

No files matched your search

@@ -0,0 +1,120 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.ws.WsState
/**
* 分享落地时机的判据([shareLanding])。
*
* 背景:主人实机复现——WS 还没连上时投递分享,得到「发送失败,请重试」+ 正文退回输入栏,
* 而后端**零请求**(`POST /api/v1/chat` 一条都没有)。原来是「等连接」那个 `withTimeoutOrNull`
* 的返回值被丢掉了 ⇒ **超时与成功走同一条下游**:照样取走内容去发。而 `ShareIntake.take()` 是
* 原子出队、没有归还路径,那一次「其实一次都没发出去」的失败把内容从待发队列里抹掉了。
*
* 本测试钉住三件事:
*
* 1. **取走内容的前提是连接就绪**:未就绪时恒不 [ShareLanding.Send](不取、不发)。
* 2. **等够久只说一次**「还没连上、连上后会自动发出」([ShareLanding.TellWaiting]),
* 同一轮等待里连接反复抖动不再说第二遍。
* 3. 反面边界:**没有待发内容**时任何连接状态下都不做事、不告知;**已经连上**时不告知
* (发送压过告知,返回值是单值动作 ⇒ 两者在类型上就不可能同时发生)。
*/
class ShareLandingTest {
@Test
fun `content is taken only once the connection is ready`() {
val ready = shareLanding(
hasPending = true,
connection = WsState.Connected,
waitedLong = false,
alreadyTold = false,
)
assertEquals(ShareLanding.Send, ready)
// 其余任何连接状态都不是「可以取走」:Idle / Connecting / Reconnecting / Offline 一律不动它。
WsState.entries.filter { it != WsState.Connected }.forEach { connection ->
listOf(false, true).forEach { waitedLong ->
listOf(false, true).forEach { alreadyTold ->
val landed = shareLanding(true, connection, waitedLong, alreadyTold)
assertTrue(
"$connection / waitedLong=$waitedLong / alreadyTold=$alreadyTold 不该取走内容",
landed != ShareLanding.Send,
)
}
}
}
}
@Test
fun `before the connection is ready nothing is taken and nothing is said`() {
assertEquals(
ShareLanding.KeepWaiting,
shareLanding(true, WsState.Connecting, waitedLong = false, alreadyTold = false),
)
assertEquals(
ShareLanding.KeepWaiting,
shareLanding(true, WsState.Reconnecting, waitedLong = false, alreadyTold = true),
)
}
@Test
fun `waiting too long with no connection says it, once`() {
assertEquals(
ShareLanding.TellWaiting,
shareLanding(true, WsState.Connecting, waitedLong = true, alreadyTold = false),
)
}
@Test
fun `an already told wait stays quiet however the connection flaps`() {
// 这一条是「不刷屏」:连接在 Connecting 与 Reconnecting 之间抖动会让界面反复问到同一个问题。
WsState.entries.filter { it != WsState.Connected }.forEach { connection ->
assertEquals(
"$connection 上不该把同一句话弹第二遍",
ShareLanding.KeepWaiting,
shareLanding(true, connection, waitedLong = true, alreadyTold = true),
)
}
}
@Test
fun `sending wins over telling and they never happen together`() {
// 连接一到就该发:哪怕此前已经等够久、那句「等连上会自动发」也说过了,此刻的动作也只有「发送」。
listOf(false, true).forEach { waitedLong ->
listOf(false, true).forEach { alreadyTold ->
assertEquals(
"waitedLong=$waitedLong / alreadyTold=$alreadyTold 时该发就发",
ShareLanding.Send,
shareLanding(true, WsState.Connected, waitedLong, alreadyTold),
)
}
}
}
@Test
fun `nothing pending does nothing in any connection state`() {
WsState.entries.forEach { connection ->
listOf(false, true).forEach { waitedLong ->
listOf(false, true).forEach { alreadyTold ->
assertEquals(
"$connection 上没有待发内容就不该有动作",
ShareLanding.Nothing,
shareLanding(false, connection, waitedLong, alreadyTold),
)
}
}
}
}
@Test
fun `the waiting notice says it is not connected and that it will go out by itself`() {
assertTrue(SHARE_NOT_CONNECTED_NOTICE.contains("还没连上"))
assertTrue(SHARE_NOT_CONNECTED_NOTICE.contains("自动发出"))
// 「失败」「重试」这一族措辞会把用户推向「重新分享一遍」——内容明明一条没少、就在队列里等着。
assertFalse(SHARE_NOT_CONNECTED_NOTICE.contains("失败"))
assertFalse(SHARE_NOT_CONNECTED_NOTICE.contains("重试"))
}
}