Skip to content

feat(wsl): manage opencode servers inside WSL distros (Windows desktop) - #164

Open
rayn1314 wants to merge 15 commits into
lehhair:mainfrom
rayn1314:feat/wsl-server-management
Open

rayn1314 wants to merge 15 commits into
lehhair:mainfrom
rayn1314:feat/wsl-server-management

Conversation

@rayn1314

@rayn1314 rayn1314 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

起因

我在 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 编译分支注册)。

现在能做到什么

设置 → 服务器里:

  • 自动探测本机 WSL 运行时,没装会引导安装(UAC 提权);装了会列出本地发行版 + 在线目录(自动过滤 docker-desktop 这类不可用的,WSL1 发行版禁用)
  • 一键把发行版添加为服务器:自动检查里面有没有 bash / curl / opencode,缺 opencode 可以直接安装或升级,添加后立即启动
  • 每张 WSL 服务器卡片(连接面板里和普通服务器同款交互):状态灯(启动中 / 就绪 / 失败 / 停止)+ 失败原因、健康检查(延迟 + opencode 版本)、一键订阅进多服务器侧边栏(带 WSL 徽标)、设为默认服务器、安装 / 更新 opencode、失败后重试启动、删除(带确认,同时清掉探测记录)
  • 连接自愈:WSL 服务器的端口和密码每次启动都会变,现在启动后自动更新地址并重连事件流;启动失败自动回退本地服务器,WSL 恢复后自动切回;重启应用也不会把过期的 WSL 地址存成「死连接」
  • 「打开终端」入口直接进对应发行版

实现方式挑重点

  • 后端(Rust / Tauri):wsl.exe 进程管理 + sidecar 启动脚本(stdin 下发 bash -se,PATH 清洗 /mnt/*、WSLENV、禁用 filewatcher,对齐官方);端口每次启动动态分配(持久化的只有 id + 发行版,端口与密码不落盘);UUID 密码 + Basic auth;生命周期状态经 wsl-state 事件全量推送,前端订阅渲染
  • 前端(React):settings-model.ts 纯函数层负责所有「该显示什么、下一步探测什么」的判断(对齐官方同名文件,含 fuzzysort 搜索、probe 计划、失败门控),React 组件只做渲染,方便单测
  • 添加弹窗是完整状态机:运行时探测 → 发行版列表 → 逐个探测依赖 → opencode 检查,任何一步失败都能看到原因并手动重试

截图

6ffa9866-59de-4f16-a5b0-573f083becef c3d356eb-a647-4f93-a74f-9bbdd30bd89a 3d572f93-73ef-4c0a-80a1-e6b09ea1c355

后续改进(启动性能与加载体验)

功能做完真机用了一阵,发现初始启动的加载明显变慢(侧边栏会话列表、模型选择栏都要等更久),用 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)、离线机器上后台刷新会反复拉起挂住的联网进程(加了在途去重)等。

踩过的坑(挑几个印象深的)

  1. Rust 和前端的字段名各说各话:serde 默认 snake_case,TS 类型写的 camelCase,前端读到的全是 undefined,UI 稳定显示「请先打开一次发行版」。现在所有跨语言结构体统一 #[serde(rename_all = "camelCase")],命名有机械保证。
  2. wsl --list --online 要联网:网络慢时超时会把已经成功的本地列表一起丢掉,还伪装成「没有发行版」。现在本地列表和在线目录解耦容错,谁失败只报谁。
  3. 重写进程封装时漏了 Stdio::piped():子进程输出全拿空串,探测「成功」但数据全空,上层误判成没数据就无限自动刷新,整个弹窗卡死。run_process 已补回归测试;sidecar 是内联 Stdio 配置,不被该测试覆盖。
  4. Windows 分支曾另起一次 invoke_handler 注册 WSL 命令——Tauri 的 invoke_handler 是整体替换语义,第二次调用会把第一次注册的全部命令覆盖掉,安装包表现为「进程在但窗口永远不出来」。现在 WSL 命令合并进同一个 generate_handler! 列表。
  5. wsl.exe 的管道输出是 UTF-16LE(可能带 BOM)和 UTF-8 混杂,不解码就是乱码。

验证

  • typecheck 0 错误;ESLint 0 错误
  • 测试 644/644 通过(94 个文件)
  • vite 生产构建通过;cargo check / cargo test(16 个后端测试)通过
  • 实际使用体验:切换到 WSL 服务器后,会话里跑的工具(执行命令、读写文件)都是 WSL 环境内部的——路径、shell、权限都是 Linux 侧的,不是本地中转转发式的实现
  • Tauri release 构建出安装包,真机(Win11 + WSL2 Ubuntu)完整验收:添加 → 自动装 opencode → 启动 → 订阅切换 → 设默认 → 健康检查 → 重试启动 → 删除,全流程可用
  • 基于 0.6.45(e3605b57),与当前 main 仅差一个无交集的依赖 bump(fast-uri),合并无冲突

提交组织

为方便审计拆成九个提交:

  1. chore: exclude local opencode reference dir from git, eslint and vitest —— 排除我做官方源码对照用的本地目录
  2. feat(wsl): backend server management commands for Windows desktop —— src-tauri 全部(7 文件)
  3. feat(wsl): frontend server sync, settings UI and connections integration —— src 全部 + package.json(21 文件)
  4. fix(wsl): sidebar group and SSE follow WSL server registered after boot —— WSL 服务器启动后就绪后自动注册进侧边栏并接通事件流
  5. perf(wsl): 按需预热——启动零探测、设置页意图驱动、在线目录 TTL 缓存 —— 启动路径只恢复状态,探测按需
  6. perf(sessions): 修复启动级联——未注册服务器过滤、upsert 短路、订阅集合增量管理 —— 消灭启动窗口期的重复加载与数据串服
  7. fix(sidebar): 会话列表加载态与错误态——重试间隙不再闪现空态文案
  8. fix(wsl): 评审修正——在线目录 revalidate 去重、prewarm 跳过就绪服务器、测试门控简化
  9. fix(sessions): 评审修正——过滤后兜底 active server、enabled 关闭中断重试、去除空 catch

前后端拆开但没拆更细的原因:wslStore 和 serverStore 互相引用对方本次新增的方法,行级拆分会产生无法通过 typecheck 的中间提交;性能与体验修复(5-9)按主题拆分,每个提交都能独立通过校验。

Closes #161

关于 AI 辅助

这次的排查和实现过程使用了 AI 辅助(对照官方源码做差距清单、定位跨语言序列化问题等),但上面每一个功能点都是我在真机上逐一实测验收过的。

- 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
@lehhair

lehhair commented Aug 30, 2026

Copy link
Copy Markdown
Owner

感谢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 lehhair left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 通过(描述里的测试数准确)

顺带说明:devmainorigin/devorigin/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:218useGitWorkspaceCatalog.ts:158WorktreePanel.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 分支直接 returnwsl_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

三个后果:

  1. 删掉正在用的 WSL 服务器后,active 仍指向死地址,removeServer 里新加的 server-switch 兜底(serverStore.ts:578-580)不会被触发;
  2. 残留条目带着一次性密码和死端口继续被 multiServerStore 白名单订阅——WslServerRow.tsx:231-234 的删除只调 wslApi.removeServer,没有像普通服务器删除那样清白名单(ServersSettings.tsx:684-690)→ 侧边栏出现永远连不上的 WSL 分组;
  3. 设置页对"有 id 但没有 wsl 状态"的条目 return nullServersSettings.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 == truefailure.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_internalStarting、解析路径、分配端口、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_distromatch (installed, online, probe) 任一 Err 都整体 Err(e)联网拉在线目录失败会把已经安装成功的 installed + probe 一起丢弃,前端报错但发行版其实装好了。这与描述里"本地列表和在线目录解耦容错,谁失败只报谁"直接矛盾(refresh_distros_inner 才是容错的)。装上发行版不会改变在线目录,这里也可以直接不拉
I2 main.tsx:63SessionContext.tsx:221useSessions.ts:285useProject.ts:79useVcsInfo.ts:81BottomPanel.tsx:102pinnedSessionsStore.ts:61modelVisibilityStore.ts:24 约 10/12 个 onServerChange 消费方忽略 reason。非 active 的 WSL 服务器重启会波及 active 路径:main.tsx:63resetPathModeCache() + 重载 auto-approve + 强制重连 active 的 SSE(重连窗口内事件会丢),SessionContext 会清空并重拉整个会话列表(可见闪烁)。目前只有 useModels.tsDirectoryContext.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-551opencode.rs:400-406 退出路径没有 RunEvent::Exit/ExitRequested 钩子,stop_all_wsl_servers 只在 confirm_close_app 里被调,而它全仓库只有 useCloseServiceDialog.ts:35 一处调用、且只在"本应用启动过 Windows 本地服务"时才走 → 直接关窗会留下所有 WSL opencode servesidecars 是进程内 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-11331046-1073901-912 add_wsl_server 先改内存后持久化(写盘失败留幽灵 Starting);watcher 用 if let Ok(status)wait() 返回 Err 时既不清理也不改状态 → 永久 Ready 挂着死句柄;sidecar Commandkill_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 + 强制"重新检测"这些产品判断都不错。

需要改

  1. FolderRecentList.tsx:1099 新加的 text-error-100 hover:text-error-200src/index.css@theme只有 --color-danger-*,没有 --color-error-*,这两行 class 不生成任何 CSS → "加载失败,点击重试"根本不会变红,跟空态文案视觉上没区别(MultiServerFolderList.tsx:72SearchResults.tsx:32 是既有同类隐患)。请改 text-danger-100
  2. MultiServerFolderList.tsx:224 的 WSL 徽标用 text-blue-400 bg-blue-400/10——全项目唯一使用 Tailwind 原生调色板的地方,其它一律语义 token,两套主题下都不受控。
  3. WslServerRow.tsx:213-224 用字面 × 做删除,而同一个"移除服务器"动作在 ServersSettings.tsx:151-162TrashIcon size={13} → 请统一。
  4. 窄窗口溢出:操作区是 shrink-0 + 全文字按钮,zh 下固有最小宽度约 456px;设置弹窗是 min(97vw,1040px) 减 204px 导航,而默认窗口是 800×600tauri.conf.json:19-20,无 minWidth)→ 内容列 ~520px,active + default + 需要更新的一行会横向溢出。建议次要动作改图标按钮(与 ServerItemPencilIcon/TrashIcon 一致)或允许换行。
  5. 同一张卡上两个视觉完全相同的 Wi-Fi 状态(左侧 runtime StatusDot 与右侧 ServerHealthButton),且 stoppedfailed 同形同色,用户无法区分"我停的"和"它崩了"。
  6. "设为默认服务器"只长在 WSL 卡片上,但 defaultServerId 是全局启动偏好(serverStore 对普通 id 同样生效、还会进备份文件)→ 概念只在 WSL 处露出,删掉那台服务器后偏好变成不可见、不可清除的孤儿状态。建议要么全局可见可管理,要么明确限定为 WSL 专属并在文案/文档里说清。
  7. "安装 WSL"会直接弹 UAC 提权DialogAddWslServer.tsx:202wsl_runtime.rs:168 Start-Process -Verb RunAs),但文案没有一句提前说明"将请求管理员权限",用户拒绝后只会看到一个英文 stderr 原样抛出的提示。建议加一句预告 + 把"取消/拒绝提权"单独识别成一条可读文案。
  8. 硬编码尺寸:settings-model.ts:240width: '138px'/'129px'DialogAddWslServer.tsx:264width: '99px' 是按 zh 字宽写死的,英文或更长语言会挤/裁。
  9. DialogAddWslServer.tsx:467fill="#DBDBDB" 内联 SVG 在浅色主题面板上几乎不可见;另有两个自绘 SVG 重复了 Icons 里的 DownloadIcon/ChevronRightIcon
  10. 小项:禁用发行版行 tabIndex={undefined} 导致键盘用户读不到"WSL 2 required"的原因;探测中状态没有 role="status"/aria-live(仓库 ModelSelector.tsxSettingsSearch.tsxChatArea.tsx 已有该模式);{t('common:add')} ▾aria-haspopup/aria-expandedDialogAddWslServer.tsx:186-206unavailable 非 installable 分支没有"重新检测"入口,只能关掉重开。

与 PR 描述不一致的 3 处(以代码为准比较稳妥)

  1. "动态端口分配并持久化复用" —— WslServerConfig 只有 {id, distro},没有端口字段,每次启动都重新 allocate_port()wsl_commands.rs:713),代码注释自己写的也是"无端口字段"。
  2. "所有跨语言结构体统一 #[serde(rename_all = "camelCase")],命名有机械保证" —— WslJobwsl_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"
  3. "补回归测试防再犯(Stdio::piped)" —— test_run_process_captures_output 只覆盖 run_process,sidecar 是内联自己配的 Stdio(wsl_commands.rs:901-912),漏配不会被这个测试抓到。

合前请顺手清理

  • .gitignore 里的 .hermes/ .omo/ opencode/ nuleslint.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 上)

  1. events.tsserver-runtime-updated 归属:active 是 A 时收到 B 的 runtime 变更,不得迁移订阅。
  2. wslStore._syncServers 的删除对账:状态里消失的 id 必须从 serverStore/白名单中清除。
  3. Rust 健康检查超时:把"是否超时"抽成纯函数测试(与 decide_online_action 同一风格)。
  4. 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,按钮随长文案自然扩展不裁切
@rayn1314

Copy link
Copy Markdown
Contributor Author

问题全部认账,已在原分支追加 6 个提交修复(7096f48d → 31f95a6),没有 force push。逐条说明:

B1 SSE 订阅迁移:确认。server-runtime-updated 之前无条件触发订阅迁移,非活动服务器(比如后台就绪的另一台 WSL)广播时会把活动订阅从 local 拆掉重建到对方 URL 上。修复是在迁移分支里比对事件服务器与订阅当前跟随的服务器:非活动服务器的 runtime 更新只走 upsertServer 更新记录,不动 EventSource;真实 server-switch 和活动服务器自身的 runtime 变更照常迁移。

B2 服务器删除回收:确认。wslStore 每轮同步(事件推送与初始 getState 两条路径共用的 _syncServers 入口)对账新旧 id 集合,对消失的服务器摘 multiServerStore 白名单订阅、走 serverStore.removeServer 完整注销。另外把您点到的三个指针一并处理了:bootTarget/pendingRestoreId 在回收时清掉(不摘的话重新添加同名发行版会被静默劫持回 active);defaultServerIdremoveServer 里清理,但有意做了一个分工:WSL id 的"从列表消失"经常只是 stop/未就绪的瞬时状态(配置仍在后端),这种移除不清默认偏好,只有 _reclaimRemoved 判定的真删除才清——这是对您建议修法的一处偏离,理由写在代码注释里。

B3 健康轮询 timeout 腿:确认,这个最隐蔽。ready 标志让循环在进程存活但端口永不同通时根本走不到超时判定,那段判定本身是死代码。修复:超时判定抽成纯函数(与既有 decide_online_action 同款直测),deadline 在循环内用 tokio::time::Instant 单调时钟核算(避免墙钟校时把预算拉长),30s 预算耗尽以 Failed 收敛;死代码与 ready 标志删了。超时预算提为 HEALTH_TIMEOUT_MS 常量,测试与生产共用同一数字。

B4 句柄所有权:确认。cleanup_sidecar 原来无条件按 id 移除,被顶替的旧启动流程晚醒(比如卡在健康 HTTP 调用里、期间用户重试且新流程已注册自己的句柄)会把新流程的所有权凭证删掉——之后 stop/remove 找不到 token,opencode serve 泄漏在发行版里占着端口,命令照样返回 Ok。修复:cleanup_sidecar 带 attempt 参数,归属判定抽成纯函数,判断和摘除在同一把锁内完成并返回 owned;5 处调用点全部改为带 attempt 认领,调用方只在 owned 且 attempt 仍是当前时才上报退出失败。stop_sidecar_internal 保持唯一无条件移除点(主动停止方自持所有权),注释写明。

正文描述不实三处:端口"持久化复用"不实——WslServerConfig 只存 id + 发行版,端口每次启动重新分配,PR 正文已更正;WslJob 确实漏了字段 camelCase(enum 上的 rename_all 只改 variant 名、不改字段,实测输出 started_attypes.ts 声明 startedAt),补 rename_all_fields = "camelCase" 并加一条序列化契约测试锁定前端声明的形状;Stdio::piped 回归测试只覆盖 run_process、sidecar 内联配置不被覆盖,原文"防再犯"说法过强,正文已收窄措辞。

重连空操作(评审顺带确认):引用计数下"先 subscribe 后 unsubscribe"对同一服务器只是 1→2→1,连接从未换到新端点。改走 reconnectServerSSE 定向真重连,只动变更那条连接,不碰活动服务器的 SSE。

事件消费方门控:8 个 onServerChange 消费方此前会把任何服务器的端点变化当成活动服务器变化来处理(重置缓存、重拉数据、重连活动 SSE——重连窗口会丢事件)。现在统一门控:非活动服务器只做记录 upsert,不动活动数据与连接。useSessions 的重试退避做成可取消,unmount 后不再继续发请求。

Rust 容错一组add_wsl_server 改为先持久化成功再改内存态(失败不留幽灵条目);watcher 意外 Err 按进程异常退出处理,不再静默;sidecar Commandkill_on_drop 防句柄泄漏;安装完成判定不再拉取在线发行版目录,抽成纯函数直测。

UI 一组:死色 token 换成 @theme 实存的语义 token;WSL 行删除按钮从文本 × 统一为 TrashIcon;操作区折行防窄窗口溢出;会话列表刷新失败改为露出失败态并给重试入口(此前被"显示更多"按钮吞掉,有旧数据时失败完全不可见);引导对话框补 role=status/aria-live/禁用行 aria-describedby、权限请求提示与 UAC 拒绝文案(en/zh 对称);固定 width 改 minWidth,按钮随长文案自然扩展。

合前清理mod.rs 恢复 LF(git diff --ignore-cr-at-eol 零字节,纯行尾噪音);.gitignore/eslint.config.js/vitest.config.ts 里混进来的本机条目回退。

证据

  • cargo test 25/25(wsl_commands.rs 测试 6→15:超时边界、归属交错、安装判定、serde 契约);cargo check --all-targets 无警告
  • tsc -b 0 错误;eslint . 0 error、71 条 warning 与改动前基线持平(改动文件单跑无新增告警);vite build 通过
  • 本次涉及的 9 个 TS 测试文件单跑 116/116;全量 657/658,唯一失败是已知的 ConfigSettings.search 并发抖动(单跑必过;本机负载下未改动的基线同样会挂若干条且每次集合不同,挂的均与改动文件零交集)——这个环境的数字我只敢报到这个粒度
  • 红→绿验证:临时把门控/重连修复还原成旧实现,7 条新增行为测试逐条红、还原即绿;Rust 新测试直接针对新增纯函数,旧代码上编译即失败

接线说明(证据分级):Rust 侧超时判定与句柄归属是纯函数直测;它们与轮询循环、5 处调用点的接线是静态核对 + 编译器对签名变更的兜底(漏改一处编不过),没有真进程级并发集成测试。

剩余:窄窗口布局重设计、双连接状态区分、"设为默认"的全局可见性属于要先定设计方向的界面项,sidecar kill 升级路径需要真机时序验证,这些我另开 follow-up PR 跟进。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Windows 桌面端支持 WSL 连接

2 participants