feat(wsl): manage opencode servers inside WSL distros (Windows desktop) - #164
Conversation
- WSL runtime probe, distro enumeration (local + online), per-distro probes and opencode version checks with UAC-elevated installers - server lifecycle jobs (start/install/remove) with generation-guarded state, UTF-16 console decoding, full state push via wsl-state event - deterministic server ids (wsl:<distro>), startup warm-up - opencode command surface extended for WSL sidecar lifecycle
- wslStore syncs runtime into serverStore: ready servers register with auth and auto-subscribe to the multi-server sidebar (WSL badge) - connections list renders one card per WSL server combining connection and lifecycle management (switch, health, default, install/update opencode, retry, remove); add dialog with detection progress state and terminal entry - default-server preference persisted (boot auto-switch, death- rebirth restore after sidecar restart); WSL ids excluded from localStorage persistence; SSE resubscribes on same-id runtime change
- upsertServer broadcasts server-runtime-updated on any endpoint change (first registration included), decoupled from the active server - SidePanel subscribed filter tracks the serverStore snapshot so the WSL group appears the moment the server registers, no manual re-subscribe - per-server SSE rebuilds the single affected connection on runtime change: the boot-window fallback connection (WSL not yet registered) stays healthy on the wrong address and never self-heals via auto-reconnect
|
感谢pr,最近工作太忙,更新比较慢,抽空我会看看的 |
- initialize_wsl 瘦身为仅恢复配置+拉起已有服务器,删启动全量 opencode 检查与预热块 - 新增 prewarm_wsl 命令,设置页打开时按需补齐 runtime/发行版列表/opencode 检查 - 在线目录 stale-while-revalidate 缓存(wsl-online-cache.json, TTL 24h):联网目录持久化,过期先展示后后台刷新 - 对话框「重新检测」按钮走 force=true 绕过缓存,autoProbePlan 走缓存 - 未添加过 WSL 服务器的机器启动路径零开销
- collectActiveServerIds 过滤未注册服务器,消灭 WSL 就绪前请求回退 local 的数据串服 - 注册事件订阅 serverStore,WSL 就绪时自动入集触发接入 - upsertServer 无变更短路,wsl-state 全量推送不再触发 localStorage 写入与全体重渲染 - 订阅集合改增量 diff(Map 按 serverId),集合变化不再全量拆建 SSE 与全量重拉 - useModels 按 reason 门控,非 active 服务器端点变化不再触发模型重拉 - 补增量接入与定向重建订阅的契约测试
- useSessions 重试改为显式循环,loading 生命周期=循环生命周期,重试等待期不落地 - 重试耗尽才落 error 终态,空态文案不再伪装成「没有对话」的加载失败 - FolderRecentList 空态前增加 error 分支,加载失败显示可点击重试 - 新增 locale 键 sidebar.loadFailed - 补重试期保持 loading 与耗尽落 error 的契约测试
- 后台 revalidate 加在途标志,避免每次 ServeStale 并发拉起会挂住的 wsl --list --online - prewarm 只补未就绪发行版的 opencode 检查,就绪服务器由 Ready 挂钩刷新,不再重复 spawn - 单元测试去掉冗余 target_os 门控(模块本身已按 Windows 编译)
- collectActiveServerIds 过滤后为空时兜底回退 active server,避免 SSE 全灭(补回归测试) - useSessions 重试等待期检查 enabledRef,懒加载闸门关闭时中断在途重试 - 重试终态改为 return 而非 throw 到空 catch,消除空 catch 块
lehhair
left a comment
There was a problem hiding this comment.
Review:功能方向很对,架构方向也对,但现在还不能合(4 个 blocker)
先说结论:这个 PR 的价值我认可——WSL 里 opencode serve 端口/密码每次启动都变,做成"像本地服务一样托管生命周期"是对的方向,#161 的痛点是真的。分层也符合项目既有风格(Rust runtime/命令/类型三层 + 纯函数决策 + job 代次;前端 wslStore 只做粘合 + settings-model.ts 纯函数 + serverStore 持久化边界),Windows 隔离干净(commands/mod.rs 三个模块全部 #[cfg(target_os = "windows")],12 个命令合并进同一个 generate_handler!,lib.rs 无第二处注册——描述里提到的 invoke_handler 覆盖 bug 确实已经修掉了)。
但我把分支拉到本地跑通、并对关键路径做了可复现验证后,发现 4 个 blocker,其中 2 个会造成"跨服务器数据串扰 / 幽灵连接",2 个会造成"服务永久卡在启动中 / 孤儿进程无法回收"——恰好都是这个 PR 自己宣称要消灭的失败类型。修复量都很小(合计约 30~50 行),所以我建议 Request changes,改完再合,而不是重做。
我实际跑过的校验(在 PR head 上)
| 命令 | 结果 |
|---|---|
npm run typecheck |
0 错误 |
npm run lint |
0 错误 |
npx vitest run |
94 文件 / 644 测试全过 |
npm run build |
vite 生产构建通过 |
cargo check --all-targets |
通过,0 警告 |
cargo test |
16/16 通过(描述里的测试数准确) |
顺带说明:dev、main、origin/dev、origin/main 都在 d823f189,与 PR base e3605b57 只差一个 fast-uri 依赖 bump,git merge-tree 无冲突 —— 与描述一致。
但请特别注意:现有 644 个测试一个都没抓到底下的 blocker。 下面每条我都标了证据等级(实测 = 我写了临时探针测试跑出来的原始输出;静态可证 = 控制流/可达性上确定;需真机确认 = 我无法在本机复现,只能审代码)。
Blocker 1(实测)subscribeToEvents 会把"活动服务器事件流"迁移到非活动服务器
src/api/events.ts:1039-1047
if (newServerId === currentServerId && reason !== 'server-runtime-updated') return
unsubscribe()
currentServerId = newServerId
unsubscribe = subscribeToServerEvents(currentServerId, callbacks)ids 不同时这个守卫完全不生效,而 serverStore.upsertServer 是对所有服务器无条件广播 server-runtime-updated 的(serverStore.ts:506-511)。于是:任何一台 WSL sidecar 就绪(含应用启动时 initialize_wsl 拉起全部已添加服务器的那一轮,首次注册 previous 为空 → runtimeChanged = true),都会在 active 是别的服务器时,把 active 的 SSE 拆掉、迁到那台 WSL 服务器上,并把 currentServerId 改成它。
我用真实 events.ts + 真实 serverStore(active = local,只调一次 upsertServer({id:'wsl:Ubuntu'}))跑出来:
PROBE active server at subscribe time: local
PROBE fetch #1: http://srv.test/local/global/event
PROBE fetch calls after WSL ready: ["http://srv.test/local/global/event","http://srv.test/wsl:Ubuntu/global/event"]
PROBE active server unchanged: local
active 还是 local,但事件流已经连到 wsl:Ubuntu 了。消费方随即拿到错误服务器的事件:SessionContext.tsx:153(按裸 session.id 入列表,无 server 作用域)、useSessions.ts:218、useGitWorkspaceCatalog.ts:158、WorktreePanel.tsx:108;正在用的那台服务器实时更新中断,直到下一次真正的服务器切换。这正是 PR 要消灭的"数据串服"。
最小修法:
if (reason === 'server-runtime-updated') {
// 端点变化只关心 active 自己;非 active 服务器的端点变化与本订阅无关
if (newServerId !== serverStore.getActiveServerId()) return
} else if (newServerId === currentServerId) return(这个分支 events.test.ts 完全没覆盖。)
Blocker 2(实测)删除 WSL 服务器后,serverStore 留下幽灵服务器
src/store/wslStore.ts:150-183
_syncServers 只遍历 state.servers,而"未就绪 → serverStore.removeServer"那条路对已删除的服务器永远不会执行:后端 remove_wsl_server 是把配置 retain 掉再推送全量状态(wsl_commands.rs:1159),stop_sidecar_internal 又刻意不改 runtime,监督任务的 cancel 分支直接 return(wsl_commands.rs:1046-1052)——前端再也不会看到这个 id。
实测(ready 注册 → 设为 active → 后端推送删除后的空状态):
PROBE after ready -> serverStore: [ 'local', 'wsl:Ubuntu' ] subscribed: [ 'wsl:Ubuntu' ] active: wsl:Ubuntu
PROBE after delete -> serverStore: [ 'local', 'wsl:Ubuntu' ] subscribed: [ 'wsl:Ubuntu' ] active: wsl:Ubuntu
三个后果:
- 删掉正在用的 WSL 服务器后,active 仍指向死地址,
removeServer里新加的 server-switch 兜底(serverStore.ts:578-580)不会被触发; - 残留条目带着一次性密码和死端口继续被
multiServerStore白名单订阅——WslServerRow.tsx:231-234的删除只调wslApi.removeServer,没有像普通服务器删除那样清白名单(ServersSettings.tsx:684-690)→ 侧边栏出现永远连不上的 WSL 分组; - 设置页对"有 id 但没有 wsl 状态"的条目
return null(ServersSettings.tsx:655-659)→ 用户在 UI 里再也看不到、也删不掉它。
最小修法:在 _syncServers 里记住上一次同步过的 id 集合,对本轮缺失的 id 做对账:
for (const id of this._knownIds) {
if (state.servers.some(s => s.config.id === id)) continue
multiServerStore.setSubscribed(id, false)
serverStore.removeServer(id) // 顺带触发 active 回退
if (serverStore.getDefaultServerId() === id) serverStore.setDefaultServer(null)
}
this._knownIds = new Set(state.servers.map(s => s.config.id))顺带建议在 serverStore.removeServer 里清掉指向被删 id 的 defaultServerId(现在的 defaultServerId 是只增不减的)。
Blocker 3(静态可证)健康检查轮询没有超时,30s 超时分支是不可达死代码
src-tauri/src/app/commands/wsl_commands.rs:971-1011
while !ready && failure.is_none() {
if !is_current() { ...; return; } // 出口 1
if let Ok(Some(status)) = child.try_wait() { failure = Some(...); break; } // 出口 2
if is_service_running_with_auth(&url, ...).await { ready = true; break; } // 出口 3
sleep(100ms).await;
}
if !ready && failure.is_none() { // ← 恒为 false,30s 超时提示写在这里循环只能从 ready / failure / return 退出,所以出循环时必有 ready == true 或 failure.is_some(),health_timeout_ms(973)只在这个死分支里被用到——注释里承诺的 Promise.race([health, exit, timedOut]) 的超时臂从来没写。
后果:如果 opencode serve 活着但 127.0.0.1:<port> 永远不响应(WSL localhost 转发失效是真实且常见的故障),任务会以 10Hz 永远轮询,服务器永远停在 Starting、UI 所有按钮永久禁用、任务永不回收(每轮健康检查本身还带 3s connect + 5s request 超时,单轮最坏 ~8s)。修法:循环内用 tokio::time::Instant deadline,超时落 Failed,同时删掉不可达分支。
Blocker 4(静态,交错分析成立;触发率需真机确认)cleanup_sidecar 会删掉别人的句柄 → 孤儿进程
src-tauri/src/app/commands/wsl_commands.rs:1076-1081
/// 从 sidecars 移除本次启动的句柄(仅当 token 匹配时) // 注释承诺了校验
fn cleanup_sidecar(state: &tauri::State<'_, WslState>, id: &str) {
if let Ok(mut sidecars) = state.sidecars.lock() { sidecars.remove(id); } // 实际无条件
}remove(id) 是无条件的,而 1055-1059 行已经算好了正确的 attempt 归属判定(owned = sidecars.get(id).map(|h| h.attempt == attempt))却没用在删除上。可达交错:attempt N 正卡在最长 5s 的健康检查 HTTP 调用里 → 用户点"重试启动"或安装 opencode → attempt N+1 走完 stop_sidecar_internal、Starting、解析路径、分配端口、spawn、注册新句柄 → N 醒来发现 !is_current(),child.kill() 后调 cleanup_sidecar 删掉 N+1 的句柄。此后 stop_wsl_server / remove_wsl_server / 退出清理都找不到 token,opencode serve 永久留在发行版里占着端口,而删除命令还返回 Ok(())。
最小修法:给 cleanup_sidecar 加上 attempt 参数,只在仍属于自己时才 remove;让 stop_sidecar_internal 成为唯一的无条件移除点。
建议同批修的重要问题
| # | 位置 | 问题 |
|---|---|---|
| I1 | wsl_commands.rs:633-647 |
install_wsl_distro 里 match (installed, online, probe) 任一 Err 都整体 Err(e):联网拉在线目录失败会把已经安装成功的 installed + probe 一起丢弃,前端报错但发行版其实装好了。这与描述里"本地列表和在线目录解耦容错,谁失败只报谁"直接矛盾(refresh_distros_inner 才是容错的)。装上发行版不会改变在线目录,这里也可以直接不拉 |
| I2 | main.tsx:63、SessionContext.tsx:221、useSessions.ts:285、useProject.ts:79、useVcsInfo.ts:81、BottomPanel.tsx:102、pinnedSessionsStore.ts:61、modelVisibilityStore.ts:24 等 |
约 10/12 个 onServerChange 消费方忽略 reason。非 active 的 WSL 服务器重启会波及 active 路径:main.tsx:63 会 resetPathModeCache() + 重载 auto-approve + 强制重连 active 的 SSE(重连窗口内事件会丢),SessionContext 会清空并重拉整个会话列表(可见闪烁)。目前只有 useModels.ts 与 DirectoryContext.tsx 处理了 reason |
| I3 | useGlobalEvents.ts:812-818 |
这段"端点变了要拆旧建新"实际是空操作:subscribeToServerEvents 是引用计数的(events.ts:1011-1029),先订阅后取消 → size 1→2→1,既不 disconnect 也不 connect。实测:set-then-unsubscribe 后 fetch 次数没变(1),而 reconnectServerSSE 会真的重连(2)。建议直接调 reconnectServerSSE(changedId) 或先取消后订阅 |
| I4 | app/mod.rs:525-551、opencode.rs:400-406 |
退出路径没有 RunEvent::Exit/ExitRequested 钩子,stop_all_wsl_servers 只在 confirm_close_app 里被调,而它全仓库只有 useCloseServiceDialog.ts:35 一处调用、且只在"本应用启动过 Windows 本地服务"时才走 → 直接关窗会留下所有 WSL opencode serve;sidecars 是进程内 map,下次启动也回收不了(initialize_wsl 对空 map 调 stop) |
| I5 | useSessions.ts:126-164 |
重试循环无法在 unmount 时中断(base 里 retryTimerRef 会清),卸载后仍会多发最多 3 次请求(~5s);另外终态不再 setSessions([])/setHasMore(false) 是全平台契约变更,而 FolderRecentList.tsx:1094-1102 只在列表为空时显示错误 → 有旧数据时"刷新失败"完全无提示,且 :1165 仍显示"显示更多" |
| I6 | wslStore.ts:26-33,71-79 |
defaultServerId/bootTarget/pendingRestoreId 对已删除服务器从不清理;WSL id 是确定性的,重新添加同一发行版会被静默切为 active/default;pendingRestoreId 无过期,会把用户主动切走的服务器"拽回来" |
| I7 | wsl_commands.rs:1124-1133、1046-1073、901-912 |
add_wsl_server 先改内存后持久化(写盘失败留幽灵 Starting);watcher 用 if let Ok(status),wait() 返回 Err 时既不清理也不改状态 → 永久 Ready 挂着死句柄;sidecar Command 缺 kill_on_drop(true)(run_process 有,sidecar 没有) |
| I8 | wsl_runtime.rs:131-137 |
kill() 只杀 wsl.exe,发行版内进程可能存活;在最坏路径(15 分钟安装超时)上可能出现并发安装器。这条我没法在本机复现,需要真机确认,如果确认建议在发行版内也 kill |
UI:基本符合项目设计,但有几处要收一下
做得好的:完全复用 Dialog/Button/ConfirmDialog/DropdownMenu/SettingsSection/SettingRow 与语义 token;删除用 ConfirmDialog variant="danger",与普通服务器一致;ServerHealthButton 抽出来共用是干净的复用;行内嵌按钮的 e.target !== e.currentTarget 处理正确;settings-model.ts 的纯函数 + 探针计划 + 失败门控很符合仓库风格;i18n 我核对过 en/zh 各 444 键完全对称、wsl 38 键、组件用到的键 0 缺失、无硬编码中英文串;docker-desktop 过滤、版本化 Ubuntu 去重、stale-while-revalidate + 强制"重新检测"这些产品判断都不错。
需要改:
FolderRecentList.tsx:1099新加的text-error-100 hover:text-error-200:src/index.css的@theme里只有--color-danger-*,没有--color-error-*,这两行 class 不生成任何 CSS → "加载失败,点击重试"根本不会变红,跟空态文案视觉上没区别(MultiServerFolderList.tsx:72、SearchResults.tsx:32是既有同类隐患)。请改text-danger-100。MultiServerFolderList.tsx:224的 WSL 徽标用text-blue-400 bg-blue-400/10——全项目唯一使用 Tailwind 原生调色板的地方,其它一律语义 token,两套主题下都不受控。WslServerRow.tsx:213-224用字面×做删除,而同一个"移除服务器"动作在ServersSettings.tsx:151-162是TrashIcon size={13}→ 请统一。- 窄窗口溢出:操作区是
shrink-0+ 全文字按钮,zh 下固有最小宽度约 456px;设置弹窗是min(97vw,1040px)减 204px 导航,而默认窗口是 800×600(tauri.conf.json:19-20,无minWidth)→ 内容列 ~520px,active + default + 需要更新的一行会横向溢出。建议次要动作改图标按钮(与ServerItem的PencilIcon/TrashIcon一致)或允许换行。 - 同一张卡上两个视觉完全相同的 Wi-Fi 状态(左侧 runtime
StatusDot与右侧ServerHealthButton),且stopped与failed同形同色,用户无法区分"我停的"和"它崩了"。 - "设为默认服务器"只长在 WSL 卡片上,但
defaultServerId是全局启动偏好(serverStore对普通 id 同样生效、还会进备份文件)→ 概念只在 WSL 处露出,删掉那台服务器后偏好变成不可见、不可清除的孤儿状态。建议要么全局可见可管理,要么明确限定为 WSL 专属并在文案/文档里说清。 - "安装 WSL"会直接弹 UAC 提权(
DialogAddWslServer.tsx:202→wsl_runtime.rs:168Start-Process -Verb RunAs),但文案没有一句提前说明"将请求管理员权限",用户拒绝后只会看到一个英文stderr原样抛出的提示。建议加一句预告 + 把"取消/拒绝提权"单独识别成一条可读文案。 - 硬编码尺寸:
settings-model.ts:240的width: '138px'/'129px'、DialogAddWslServer.tsx:264的width: '99px'是按 zh 字宽写死的,英文或更长语言会挤/裁。 DialogAddWslServer.tsx:467的fill="#DBDBDB"内联 SVG 在浅色主题面板上几乎不可见;另有两个自绘 SVG 重复了Icons里的DownloadIcon/ChevronRightIcon。- 小项:禁用发行版行
tabIndex={undefined}导致键盘用户读不到"WSL 2 required"的原因;探测中状态没有role="status"/aria-live(仓库ModelSelector.tsx、SettingsSearch.tsx、ChatArea.tsx已有该模式);{t('common:add')} ▾缺aria-haspopup/aria-expanded;DialogAddWslServer.tsx:186-206的unavailable非 installable 分支没有"重新检测"入口,只能关掉重开。
与 PR 描述不一致的 3 处(以代码为准比较稳妥)
- "动态端口分配并持久化复用" ——
WslServerConfig只有{id, distro},没有端口字段,每次启动都重新allocate_port()(wsl_commands.rs:713),代码注释自己写的也是"无端口字段"。 - "所有跨语言结构体统一
#[serde(rename_all = "camelCase")],命名有机械保证" ——WslJob(wsl_types.rs:96-104)用的是 enum 上的rename_all = "kebab-case",它只改 variant 名、不改 variant 字段。实测:{"kind":"runtime","started_at":7},而src/features/wsl/types.ts:66声明startedAt。当前只有kind/distro/distros被消费,所以是潜伏雷,建议加rename_all_fields = "camelCase"。 - "补回归测试防再犯(Stdio::piped)" ——
test_run_process_captures_output只覆盖run_process,sidecar 是内联自己配的 Stdio(wsl_commands.rs:901-912),漏配不会被这个测试抓到。
合前请顺手清理
.gitignore里的.hermes/ .omo/ opencode/ nul与eslint.config.js/vitest.config.ts排除本地参考目录 —— 这些是贡献者本机环境,建议不要进主干(opencode/这个名字尤其容易被误解)。src-tauri/src/app/commands/mod.rs被改成了 CRLF(main 是 LF,我做了字节级核对:PR blob CR=11 / main CR=0),导致 3 行改动显示成 11 增 5 删。建议转回 LF 再合,否则以后每次改动都有噪音。
建议补的 4 个针对性测试(正好都在 blocker 上)
events.ts的server-runtime-updated归属:active 是 A 时收到 B 的 runtime 变更,不得迁移订阅。wslStore._syncServers的删除对账:状态里消失的 id 必须从serverStore/白名单中清除。- Rust 健康检查超时:把"是否超时"抽成纯函数测试(与
decide_online_action同一风格)。 cleanup_sidecar的归属校验:旧 attempt 不得删掉新 attempt 的句柄。
合并方式
改完请在同一分支追加提交(不要 force push 重写历史),我会按仓库规范 merge --no-ff 合并,这样 GitHub 上这个 PR 会正常显示为 Merged、你的原始 commit 也保留在历史里(rebase/cherry-pick 会改 hash,PR 不会被识别为已合并)。
关于审查方式:本次审查由 AI 辅助完成(自动化跑通全套校验、逐行追踪、并用临时探针测试实测复现了上面 B1/B2/I3 与 serde 字段名四条),结论经我确认后发布。凡标注"需真机确认"的项(B4 的实际触发率、I8 发行版内进程回收)我没有在本机复现,只做了代码级分析,欢迎你用真机数据反驳或补充。
整体我很喜欢这个 PR 的架构取舍和纯函数拆分,尤其是 settings-model.ts 的探针计划 + 失败门控、reduceWslRestore 的启动/死亡-复活恢复状态机,以及 decide_online_action 这类把决策从 IO 里剥出来的写法。上面这些改完(预计 30~50 行)我很乐意直接合。
- .gitignore / eslint.config.js / vitest.config.ts:移除与本功能无关的本地条目 - mod.rs:CRLF 归一为 LF,内容零变化(git diff --ignore-cr-at-eol 验证为空)
- server-runtime-updated 事件仅在变更服务器就是 active 时触发订阅迁移, 非 active 的 WSL 服务器重启不再劫持 active 连接 - 补 3 项回归测试(events.test.ts 5→8)
- serverStore.removeServer:默认服务器指向被删 id 时清除该偏好; 仅限非 WSL(WSL 删除常因瞬时未就绪触发、配置仍在后端,其回收由 wslStore 处理) - wslStore:runtime 事件中被移除的服务器经 _reclaimRemoved 回收绑定与默认项, removeServer 复用 active 优雅降级 + server-switch 广播(幂等) - 补 5 项回归测试(wslStore 13→16、serverStore 30→32)
- 健康轮询截止时间改用 tokio::time::Instant(单调时钟,改系统表不影响判定), 超时阈值收敛为共享常量 HEALTH_TIMEOUT_MS,生产与测试同源 - cleanup_sidecar 按 attempt 句柄归属判等,防止误杀新 sidecar 实例 - 安装完成判定抽为纯函数 decide_install_finish,不再拉取在线发行版目录 - add_wsl_server 先持久化成功后再改内存态;watcher 意外 Err 按进程退出处理; sidecar Command 补 kill_on_drop 防句柄泄漏 - WslJob 补 serde rename_all_fields = "camelCase",并加字段序列化契约测试 锚定前端 TS 类型 - wsl_commands.rs 测试 6→15,cargo test 25/25 全绿
- 端点变化改走 reconnectServerSSE 定向真重连:引用计数下旧的 「先 subscribe 后 unsubscribe」是同服务器 1→2→1 空操作,连接从未换过端点 - 8 个 onServerChange 消费方(main/SessionContext/useSessions/useProject/ useVcsInfo/BottomPanel/pinnedSessionsStore/modelVisibilityStore)统一门控: 非 active 服务器的端点变化不重置 active 数据、不重连 active SSE - useSessions 重试退避做成可取消:unmount 清 timer 并唤醒循环, 卸载后不再发重试请求 - 补 6 项行为测试并改写 1 项锚定旧空操作的断言(旧实现上红、新实现上绿)
- 死色 token 换为 @theme 实存语义 token(text-error-100 → text-danger-100、 blue-400 徽章 → info-100) - WSL 行删除按钮由文本 × 统一为 TrashIcon,与 ServersSettings 一致; 操作区 flex-wrap + ml-auto,窄窗口(800×600)折行防溢出 - 会话列表刷新失败时露出失败态并给重试入口,不再被「显示更多」按钮吞掉 - WSL 引导对话框:权限请求提示与 UAC 拒绝文案(en/zh 对称补键)、 探测失败可「重新检测」、role=status + aria-live 播报、 禁用发行版行经 aria-describedby 指向原因、SVG 图标改 currentColor - settings-model 固定 width 改 minWidth,按钮随长文案自然扩展不裁切
|
问题全部认账,已在原分支追加 6 个提交修复(7096f48d → 31f95a6),没有 force push。逐条说明: B1 SSE 订阅迁移:确认。 B2 服务器删除回收:确认。 B3 健康轮询 timeout 腿:确认,这个最隐蔽。 B4 句柄所有权:确认。 正文描述不实三处:端口"持久化复用"不实—— 重连空操作(评审顺带确认):引用计数下"先 subscribe 后 unsubscribe"对同一服务器只是 1→2→1,连接从未换到新端点。改走 事件消费方门控:8 个 Rust 容错一组: UI 一组:死色 token 换成 合前清理: 证据:
接线说明(证据分级):Rust 侧超时判定与句柄归属是纯函数直测;它们与轮询循环、5 处调用点的接线是静态核对 + 编译器对签名变更的兜底(漏改一处编不过),没有真进程级并发集成测试。 剩余:窄窗口布局重设计、双连接状态区分、"设为默认"的全局可见性属于要先定设计方向的界面项,sidecar kill 升级路径需要真机时序验证,这些我另开 follow-up PR 跟进。 |
- 崩溃时记录的恢复意图(pendingRestoreId)在用户切去其它服务器后即失效; 死亡瞬间 removeServer 同步广播的 active 回退属系统行为,以 _syncing 守卫区分 - 正向路径(用户没动、复活切回)保留为契约测试;wslStore 测试 16→18, 劫持用例在修复前实现上红
- 左侧状态点改圆点语义:绿实心=就绪、红实心=崩溃(行内另有红字)、 转圈=启动中、灰空心=停止;Wi-Fi 字形只留右侧可达性探测, 消除同行双 Wi-Fi 混淆与 stopped/failed 同形同色 - 注释如实说明 stopped 仅是启动恢复初始态:当前无"停止"入口, 产品闭环缺失留待上游独立讨论,本 PR 不加按钮 - "设为默认服务器"tooltip 讲明启动自动连接语义与适用范围(en/zh 对称)
- 泄漏实态(真机 3/3 复现):未启动本地服务时关窗不走 confirm_close_app, 清理链不触发;进程收尾时运行时先死,kill_on_drop 无执行机会, wsl.exe 与 distro 内 serve 双双存活 - 修复:ExitRequested(运行时最后存活点)block_on 驱动 stop_all_wsl_servers, 外层 10s timeout;与弹框确认路径幂等共存;不走"扩弹窗条件"方案—— WSL-only 用户会被弹语义不符的本地服务框,且依赖前端存活 - 真机复测:WSL-only 三轮退出全干净(<1s);已知残余 taskkill/崩溃强杀 无钩子可运行,根治需 Job Object,作为 follow-up
|
上轮修复后,我把退出生命周期在自己机器(Windows 11 + WSL Ubuntu)上做了真机验收,抓到一个真实泄漏并已修复。本轮在原分支追加 3 个提交(31f95a68 → b168c40),仍然没有 force push。 真机验收抓到的泄漏(您标注需要真机确认的退出清理问题,坐实了): 崩溃恢复的用户意志过期:sidecar 死亡时如果用户之后手动切去了别的服务器,该服务器复活时不再把用户劫持回去(原实现会);"用户没动过、复活自动切回"的正向路径原样保留,两条都有契约测试(wslStore 测试 16→18,劫持用例在修复前实现上红)。 状态点设计定稿(同一行两个 Wi-Fi 无法区分、stopped 与 failed 同形同色那条):左侧生命周期改用圆点语义——ready 绿实心、failed 红实心(行内本就带红字报错)、starting 转圈;Wi-Fi 字形只保留给右侧可达性探测按钮。真机核对时还确认了一个事实:当前形态没有任何"主动停止"入口, "设为默认"的说明文案:tooltip 现在讲清"应用启动后自动连接"的语义与当前仅面向 WSL 服务器提供的范围(指向已删服务器的孤儿偏好上一轮已修)。 门禁: |
lehhair
left a comment
There was a problem hiding this comment.
第二轮 Review:四个 blocker 确认已修,但本轮新增对账逻辑引入 1 个真实时序 bug + 2 处门控问题
先说结论:上一轮的 4 个 blocker 我逐条实测复验,全部确实修好了,而且几处修法比我的建议更好(见下方"修得好的地方")。全套校验我重跑过,基线与描述一致。
但这一轮新增的"删除对账"逻辑本身引入了一个新的时序缺陷:它在没有排序保护的前提下,把"这一轮快照里没有的 id"一律当成"已被用户删除",于是一个陈旧快照会把刚刚登记好的服务器回收掉(我实测复现,见 N1)。这条修法很小(一处判断),但它落在本轮 diff 的核心逻辑上,所以我仍然 Request changes,改完即可合。
一、上一轮 blocker 的复验结果(我本机跑的实测/静态证据)
| 上轮问题 | 状态 | 我的复验证据 |
|---|---|---|
| B1 SSE 订阅迁移到非 active 服务器 | ✅ 已修 | 实测:active=local 时另一台就绪,fetch 只有 ["http://srv.test/local/global/event"](修复前会多出 wsl:Ubuntu 那条);真实 server-switch 仍正确迁移;同 id 的 active runtime 变更也仍会重订阅 |
| B2 删除 WSL 服务器留幽灵 | ✅ 已修 | 实测:删除后 servers=['local']、subscribed=[]、active 回退、default=null 四项全部清理干净 |
| B3 健康轮询无超时(死代码) | ✅ 已修 | 超时判定抽成 health_poll_expired 纯函数直测,deadline 用 tokio::time::Instant 单调时钟,不可达分支与 ready 标志删除,预算提为 HEALTH_TIMEOUT_MS 生产/测试共享 |
B4 cleanup_sidecar 删他人句柄 |
✅ 已修 | cleanup_sidecar(state, id, attempt) -> bool,归属判断与摘除同锁完成;5 处调用点全部带 attempt 认领;stop_sidecar_internal 保留为唯一无条件移除点 |
| I1 安装成功被联网失败吞掉 | ✅ 已修 | decide_install_finish 只吃 installed + probe,在线目录不再进同一个 match |
| I3 "拆旧建新"是空操作 | ✅ 已修 | 改走 reconnectServerSSE(changedId);实测重连产生第 2 次 fetch(原先的 set-then-unsubscribe 只 1 次) |
| I7 先改内存后落盘 / watcher 忽略 Err / 缺 kill_on_drop | ✅ 已修 | 先 persist_servers 成功再注册 runtime;waited 的 Err 与意外退出同等处理;sidecar cmd.kill_on_drop(true) |
WslJob serde 字段名不实 |
✅ 已修 | 补 rename_all_fields = "camelCase",并加契约测试断言 {"kind":"runtime","startedAt":7} 锁定前端声明 |
| 合前清理(本机噪音 / CRLF) | ✅ 已修 | .gitignore/eslint.config.js/vitest.config.ts 与 main 一致;mod.rs 字节级复验 PR blob CR=0、main CR=0(纯 LF) |
| UI 语义色 / 图标 / 可达性 | ✅ 已修 | danger-100、info-100 都是 @theme 实存 token(我核过 --info-100: 200 50% 50% 等原始定义);TrashIcon 统一;操作区 flex-wrap 防溢出;role=status/aria-live/aria-describedby;minWidth 取代定宽;fill="currentColor";两个自绘 SVG 换回 DownloadIcon/ChevronRightIcon;en/zh 各 447 键仍完全对称 |
我跑的校验(head b168c40a):tsc -b 0 错;eslint . 0 error / 71 warning(main 基线是 72 条,PR 触达文件里的 11 条告警我逐行核过全部不在新增行内);vitest run 94 文件 / 660 用例全过;vite build 通过;cargo test 25/25;cargo check --all-targets 无警告。
二、本轮新发现
N1(真实时序 bug,建议修)陈旧 getState() 快照会被当成"删除",回收刚登记好的服务器
src/store/wslStore.ts:116-142(subscribeWslState 回调与 getState().then 都直接 _state = ...; _syncServers()),配合 :182-183 的对账:
for (const id of this._knownIds) {
if (!currentIds.has(id)) this._reclaimRemoved(id) // ← 无法区分「快照陈旧」与「已被删除」
}两条路径(命令响应 vs 事件推送)走的是不同 IPC 通道,没有先后保证。若较新的事件先落地、较早发出的 getState() 快照后落地(快照里还没有那台服务器),对账就会把它判定为已删除。实测复现:
PROBE after event: {"servers":["local","wsl:Ubuntu"],"default":"wsl:Ubuntu","subscribed":["wsl:Ubuntu"]}
PROBE after stale snapshot resolved: {"servers":["local"], "default":null, "subscribed":[]}
即:刚登记好的 WSL 服务器被从 serverStore 摘掉、白名单取消订阅、默认偏好被清空。
需要说清楚的是责任边界:这个"陈旧快照覆盖新状态"的排序弱点在修复前就存在(当时后果只是显示层短暂回退,下一次事件就补回来了)。是本轮新增的对账逻辑把它从"无害"放大成"破坏性"——清掉的是用户的默认偏好与订阅意图,不会自动恢复。
最小修法(任选其一):
// 方案 A:全量事件本身就是权威快照,一旦有事件落地,初始 getState 就冗余了
let receivedLiveState = false
subscribeWslState(event => { receivedLiveState = true; this._state = event.state; this._syncServers(); this._emit() })
void wslApi.getState().then(state => {
if (receivedLiveState) return // 已有更新的全量事件,丢弃陈旧快照
this._state = state; this._syncServers(); this._emit()
})方案 B 更彻底:给 WslServersState 加单调 revision,store 丢弃 revision 不增的载荷(这也能顺带覆盖你上轮列的 minor「emit 可能乱序」——emit() 在释放锁之后才 post,两个线程理论上可以反序投递)。
N2(中等)BottomPanel 的门控比对错了对象:应该比"面板自己的服务器"
src/components/BottomPanel.tsx:105
if (reason !== 'server-switch' && changedId !== serverStore.getActiveServerId()) return这个组件的数据主体是它的 serverId prop(App.tsx:1005/1036 传的是 focusedServerId,来自焦点 pane 的 session key,:507-510),它可以不等于 active——这正是你自己在 #168/#169 里认定的那个场景(面板绑定 wsl:Ubuntu、active 还是 local)。在这种形态下,属于该面板服务器的端点变化会被这个门控跳过,终端会话列表不再恢复。
同一批修复里的 useVcsInfo.ts:85-88 是正确的写法(先判"固定服务器"模式,再判跟随 active 模式),把 BottomPanel 对齐它即可:
if (serverId) {
if (changedId !== serverId) return
} else if (reason !== 'server-switch' && changedId !== serverStore.getActiveServerId()) return(提示:#169 会给这个 effect 的依赖数组补 serverId,但补依赖不解决门控取错对象,两个 PR 都要改到才彻底。)
N3(一致性)同一批门控 6 处两种写法,建议抽一个共享判定
现在有"区分固定/跟随两种模式"的(useVcsInfo)和"只判 active"的(SessionContext:224、useProject:82、pinnedSessionsStore:64、modelVisibilityStore:27、useSessions:310、main.tsx:79、BottomPanel:105)。后 6 处里哪些是"其实只跟随 active、写法正确",哪些是"漏了固定服务器分支",需要逐个按数据主体判断——BottomPanel 就属于漏了的那一类。建议抽一个语义化谓词集中在 serverStore(或一个 util)里,例如:
/** 该服务器的变化是否应当影响某个绑定 serverId 的消费方(undefined = 跟随 active) */
export function affectsBoundServer(boundServerId: string | undefined, changedId: string, reason: ServerChangeReason): boolean这样 9 个消费方共用一处语义,避免以后再出现"复制粘贴时漏掉一个分支"。这属于清理项,不阻塞合并。
三、修得好的地方(具体到点,不是客套)
- B3 你多走了一步:我只指出"超时分支不可达",你顺手换成了
tokio::time::Instant单调时钟。墙钟校时确实会让SystemTime差值失真,这是我没想到的。 defaultServerId你对我做了有理由的偏离,而且你是对的:我上轮建议"removeServer里清默认偏好"太粗——WSL 的removeServer经常是 sidecar 崩溃的瞬时未就绪触发的,配置还在后端。你改成"只有_reclaimRemoved判定的真删除才清",并把这个分工写进注释。我实测三种形态都符合预期:未就绪时白名单与默认偏好保留({"servers":["local"],"subscribed":["wsl:Ubuntu"],"default":"wsl:Ubuntu"})、复活自动切回、用户主动切走则不劫持。- 真机验收把我标"需真机确认"的那条坐实了,并给出了我推理不出的机制:关窗后 distro 内
opencode serve与wsl.exe客户端双双存活(3/3),原因是你指出kill_on_drop在这条路径兜不住——Child 已移进阻塞在wait()的监督任务,进程收尾时 tokio 运行时先死,drop 里的 kill 没有执行机会。这个解释成立,ExitRequested的时机选择也是对的方向。 - 你如实标注了覆盖度边界:退出钩子没有单测、证据就是那组真机数据;Rust 纯函数直测与轮询循环/5 处调用点的接线只有静态核对 + 编译器签名兜底。这种分级披露比"全绿通过"有用得多,也让 review 能精准落在没被证据覆盖的地方。
- 新增测试不是走过场:
events.test.ts3 条迁移语义(含我点名缺失的那条)、wslStore.test.ts删除对账 + 崩溃恢复意图两条,Rust 侧把超时判定与句柄归属抽成纯函数直测——正好是"把 blocker 所在的状态机抽出来可测"的做法。
四、合并顺序与残留项
- 我核对了 #169(issue #168 的修复):与 #164 只重叠
src/components/BottomPanel.tsx,git merge-tree review/pr-164 review/pr-169无冲突。建议 #164 先合,再让 #169 在其上 rebase/重放 BottomPanel 那处——否则 #169 会覆盖掉 N2 的正确门控。 - 你自己列的 follow-up(Job Object /
taskkill /F与崩溃场景兜底、停止按钮闭环、窄窗口布局重设计、双连接状态区分、"设为默认"的全局可见性、sidecar kill 升级路径)我认同留作后续,不塞进这个 PR 是对的判断。
五、结论
改完 N1 就可以合(N2 建议同批带上,N3 可选)。N1 是 3 行左右;N2 是与 useVcsInfo 对齐的 4 行。改完不必再走一轮完整 review,你回一句我复跑那两条探针 + 全套校验确认即可。合并我仍会按仓库规范 merge --no-ff,保留你的原始 commit 让 GitHub 正常显示 Merged。
关于审查方式:本评论由 AI 辅助产出(自动化跑通全套校验、逐行追踪、临时探针实测),结论经我确认后发布。上面 N1/N2 是探针与代码追踪得到的,N1 的复现输出已附;凡我未在本机复现的(WSL 真机时序、退出钩子行为)都以你的真机数据为准,我不重复下判断。
事件推送与初始 getState 走不同 IPC 通道,到达顺序无保证;对账逻辑 上线后,陈旧快照会把事件刚登记的服务器误判为已删除而回收(订阅意图 与默认偏好一起被清,且不自动恢复)。按维护者方案 A:全量事件本身是 权威快照,收到任一事件后初始查询结果必然更旧,直接丢弃。 新增竞态回归用例 2 条:事件先到+陈旧快照后到→不得误回收;无事件时 初始快照照常生效(防过度清理)。
同一门控此前 6 处只判 active、2 处手写固定/跟随双分支,BottomPanel 正是 复制粘贴漏分支的实例(N2)。新增 src/store/serverChangeScope.ts: affectsBoundServer(bound, changed, reason, active) 纯函数。 刻意不闭合 serverStore 单例:消费方测试普遍整体 mock serverStore 模块, 闭包单例的谓词在 mock 环境会丢失或读到与测试假象不一致的真实 active; activeServerId 由调用点经自家句柄现读传入,单一真源在任何 mock 组合下 都成立。本次迁移 main/SessionContext/useProject/useSessions/useVcsInfo/ pinned/modelVisibility 共 7 处,行为不变;谓词契约直测 +2。
旧实现拿 changedId 与 active 比对,但面板数据主体是 serverId prop(可为 固定绑定):面板绑 WSL 而 active 在 local 时,这台 WSL 换端口重启被误跳过 (终端失联),无关服务器切换反而误触发重恢复。对齐 useVcsInfo 的绑定语义, 统一走共享谓词。依赖数组补 serverId 属 lehhair#169 范围,本提交不动。
|
三条已修, N1 按方案 A: N2 N3 一并做了:谓词落 校验(head |
lehhair
left a comment
There was a problem hiding this comment.
第三轮:N1/N2/N3 三条全部确认修好,Approve
23cf65a6 上逐条复验,三条都干净落地了。
复验结果
N1(陈旧快照误回收)——按方案 A,修得对。 wslStore.ts:120-144 用 receivedLiveState 门:任一全量事件落地即视为权威快照,迟到的 getState() 结果直接丢弃。我跑了两个方向的探针:
// 事件先落地,陈旧快照后到
PROBE after event: {"servers":["local","wsl:Ubuntu"],"default":"wsl:Ubuntu","subscribed":["wsl:Ubuntu"]}
PROBE after stale snapshot: {"servers":["local","wsl:Ubuntu"],"default":"wsl:Ubuntu","subscribed":["wsl:Ubuntu"]} ← 不再被回收
// 没有任何事件落地时,初始快照必须照常生效(防过度清理)
PROBE snapshot-only: ["local","wsl:Ubuntu"] ✅
另外我做了红绿验证:把 if (receivedLiveState) return 临时去掉后,wslStore.test.ts 的 discards a stale getState snapshot that resolves after live events 这条恰好变红,其余 19 条全绿,还原即绿。这条回归测试是真能挡住回归的,不是摆设。(验证后文件已按字节原样还原,git status 干净。)
N2 —— 门控比对对象改对了。 BottomPanel.tsx:107 改为 affectsBoundServer(serverId, changedId, reason, active),与 useVcsInfo 的绑定语义一致。你指的"旧写法两个方向都错"我核过代码确实成立:固定绑定 WSL 而 active 为 local 时,这台换端口会被 changedId !== active 误跳过;反之无关服务器切换会误触发重恢复。
N3 —— 谓词抽象得比我建议的更好。 我原先只说"抽个共享谓词",你进一步把它做成不闭合 serverStore 单例的纯函数(activeServerId 由调用点现读传入),理由写在注释里:消费方测试普遍整体 mock serverStore 模块,闭包单例的谓词在 mock 环境下要么丢符号、要么读到与测试假象不一致的真实状态(你说第一版就因此红了 4 条门控用例)。这个考虑是对的——共享谓词一旦依赖单例,测试基础设施就得跟着改,反而把简单事情弄复杂。8 个消费点(main.tsx:80、SessionContext:225、useProject:83、useSessions:311、useVcsInfo:86、modelVisibilityStore:28、pinnedSessionsStore:65、BottomPanel:107)我逐个核对已收敛,useGlobalEvents/useModels/useSessions 固定模式这三处按你说的是不同语义、确实不该并入。
门禁(head 23cf65a6,我本机重跑)
tsc -b 0 错;eslint . 0 error / 71 warning(与 main 基线 72 条持平,PR 触达文件内的告警逐行核过全部不在新增行内);vitest run 95 文件 / 664 用例全过(+4:N1 竞态 ×2、谓词契约 ×2,与你的数字一致);vite build 通过;cargo test 25/25。另:全仓没有引入任何 CRLF(我按字节扫了 50 个改动文件,CR 全为 0);en/zh-CN 各 447 键仍完全对称。
留作 follow-up 的项我认同
Job Object / taskkill /F 与崩溃场景兜底、停止按钮闭环、窄窗口布局重设计、双连接状态区分、"设为默认"的全局可见性、sidecar kill 升级路径——这些要么需要先定设计方向、要么需要真机时序验证,不塞进这个 PR 是正确判断。另外几处非阻塞清理项(Cargo.toml 里 regex/uuid/tokio-util 未按 Windows 门控、parse_* 里 Regex::new 每次编译、WslServersPlatform 这个未被使用的类型、README 未提 WSL 功能)一并留给后续,不影响合并。
三条 blocker 全部闭环,我这边没有其他阻塞意见了。按仓库规范合并(merge --no-ff 保留你的原始 commit,让 GitHub 正常显示 Merged),合完我会跑一遍合并后的校验并把结果贴出来。
关于审查方式:本评论由 AI 辅助产出(自动化跑通全套校验、逐行追踪、临时探针实测含红绿验证),结论经我确认后发布。
起因
我在 Windows 上主力用 WSL 跑 opencode(Linux 环境确实更顺手),但 OpenCodeUI 桌面端想连上 WSL 里的服务只能手动「添加服务器」填地址——而 opencode serve 每次启动端口和密码都会变,等于每次都要去 WSL 里查一遍再手填一遍,没法日常用(也就是 #161 描述的痛点)。
官方 OpenCode 桌面端已经有完整的一套 WSL 服务器管理,所以我对着它的实现(packages/desktop/src/main/wsl 和 packages/app/src/wsl)逐项对照,给 OpenCodeUI 补上了这块能力。这个功能的目标是让 opencode 真正跑在 WSL 里、像管理本地服务一样管理它的完整生命周期,而不是从 Windows 侧拼个地址远程连过去的包装。
这是 Windows 桌面端(Tauri)专属功能,Web / Docker 部署完全不受影响(WSL 命令只在 Windows 编译分支注册)。
现在能做到什么
设置 → 服务器里:
实现方式挑重点
截图
后续改进(启动性能与加载体验)
功能做完真机用了一阵,发现初始启动的加载明显变慢(侧边栏会话列表、模型选择栏都要等更久),用 DevTools Network 看了一遍请求时序,定位到三组问题,挨个修掉了:
1. WSL 探测全部改为按需触发
原来的实现是应用一启动就在后台无条件跑一轮 WSL 探测:runtime 探测、发行版列表、联网拉微软在线目录、逐个发行版查 opencode——哪怕你从没添加过 WSL 服务器也照跑,等于为用不到的功能在启动路径上付成本。现在启动只做「恢复上次状态」(读配置 + 拉起已添加的服务器),所有探测改到打开设置页时按需触发;联网的在线目录加了 24 小时缓存,先展示后刷新,点「重新检测」才强制联网。
2. 启动级联:全量拆建改增量接入
WSL 服务器就绪注册时,原实现会把所有服务器的 SSE 连接全拆全建、所有会话数据全量重拉一遍,同一份数据在启动窗口期要加载 2~3 次;另外未注册的 WSL 服务器 id 会静默回退到本地端点,把本地数据写到 WSL 的键下(数据串服)。现在未注册服务器不订阅(注册即自动接入),订阅集合按服务器增量 diff——加入只连新的,移出只拆旧的;wsl-state 全量推送的重复同步也加了短路,无变更零副作用。
3. 会话列表的加载态与错误态
网络抖动时每个文件夹的重试间隙会闪现「此文件夹中没有对话」,重试耗尽后失败还会被永久伪装成空数据。现在重试期间保持加载态,「此文件夹中没有对话」只在真的确认没有会话时出现,加载失败如实显示「加载失败,点击重试」。
提交前对这批改动做了一轮独立审查,顺手修了几个边界问题:pane 里还开着已失效服务器时 SSE 集合可能被清空(现在兜底回退 active server)、离线机器上后台刷新会反复拉起挂住的联网进程(加了在途去重)等。
踩过的坑(挑几个印象深的)
#[serde(rename_all = "camelCase")],命名有机械保证。wsl --list --online要联网:网络慢时超时会把已经成功的本地列表一起丢掉,还伪装成「没有发行版」。现在本地列表和在线目录解耦容错,谁失败只报谁。Stdio::piped():子进程输出全拿空串,探测「成功」但数据全空,上层误判成没数据就无限自动刷新,整个弹窗卡死。run_process已补回归测试;sidecar 是内联 Stdio 配置,不被该测试覆盖。invoke_handler注册 WSL 命令——Tauri 的 invoke_handler 是整体替换语义,第二次调用会把第一次注册的全部命令覆盖掉,安装包表现为「进程在但窗口永远不出来」。现在 WSL 命令合并进同一个generate_handler!列表。wsl.exe的管道输出是 UTF-16LE(可能带 BOM)和 UTF-8 混杂,不解码就是乱码。验证
提交组织
为方便审计拆成九个提交:
chore: exclude local opencode reference dir from git, eslint and vitest—— 排除我做官方源码对照用的本地目录feat(wsl): backend server management commands for Windows desktop—— src-tauri 全部(7 文件)feat(wsl): frontend server sync, settings UI and connections integration—— src 全部 + package.json(21 文件)fix(wsl): sidebar group and SSE follow WSL server registered after boot—— WSL 服务器启动后就绪后自动注册进侧边栏并接通事件流perf(wsl): 按需预热——启动零探测、设置页意图驱动、在线目录 TTL 缓存—— 启动路径只恢复状态,探测按需perf(sessions): 修复启动级联——未注册服务器过滤、upsert 短路、订阅集合增量管理—— 消灭启动窗口期的重复加载与数据串服fix(sidebar): 会话列表加载态与错误态——重试间隙不再闪现空态文案fix(wsl): 评审修正——在线目录 revalidate 去重、prewarm 跳过就绪服务器、测试门控简化fix(sessions): 评审修正——过滤后兜底 active server、enabled 关闭中断重试、去除空 catch前后端拆开但没拆更细的原因:wslStore 和 serverStore 互相引用对方本次新增的方法,行级拆分会产生无法通过 typecheck 的中间提交;性能与体验修复(5-9)按主题拆分,每个提交都能独立通过校验。
Closes #161
关于 AI 辅助
这次的排查和实现过程使用了 AI 辅助(对照官方源码做差距清单、定位跨语言序列化问题等),但上面每一个功能点都是我在真机上逐一实测验收过的。