fix(webui): 修复首次打开慢时多次点击导致的竞态报错

open_kimi_web_embedded 加 launch_lock 串行化并发调用,拿锁后重查单例;
oneshot 在锁内等待主线程建窗完成,封死命令返回后的残留空档。
spawned_by_me 门控失败路径的 server 清理,复用的 server 不再被误杀;
spawn 前用 try_wait 检查已有子进程避免覆盖泄漏。前端两个打开按钮加
busy 态防重入(打开中...)。
This commit is contained in:
KimiSwitch Dev committed 2026-09-03 10:10:40 +08:00
1 parent 8b597496b3
commit 6a7c8f5393
4 files changed
+85 -28

No files matched your search

+69 -24
View File
@@ -1014,6 +1014,9 @@ pub struct KimiWebState {
window: Mutex<Option<tauri::WebviewWindow>>,
/// The `kimi web` child spawned by this app (None when reusing a server).
child: Mutex<Option<std::process::Child>>,
/// Serializes `open_kimi_web_embedded` invocations: a rapid double-click
/// queues here instead of racing the window/server setup.
launch_lock: tokio::sync::Mutex<()>,
}
const KIMI_WEB_PORT: u16 = 58627;
@@ -1064,7 +1067,13 @@ pub async fn open_kimi_web_embedded(
app: tauri::AppHandle,
state: tauri::State<'_, KimiWebState>,
) -> Result<(), String> {
// Singleton: focus the existing window instead of creating a second one.
// Serialize concurrent invocations (rapid double-click): a second call
// waits here until the first has finished building the window, then
// re-checks the singleton below and focuses it instead.
let _guard = state.launch_lock.lock().await;
// Singleton (re-checked after acquiring the lock): focus the existing
// window instead of creating a second one.
if let Ok(guard) = state.window.lock() {
if let Some(w) = guard.as_ref() {
if w.is_visible().unwrap_or(false) {
@@ -1075,8 +1084,21 @@ pub async fn open_kimi_web_embedded(
}
}
// Reuse an already-running server, otherwise spawn `kimi web --no-open`.
if !kimi_web_alive(Duration::from_millis(800)).await {
// Reuse the server this app spawned earlier (still running), or a server
// already running outside the app; only spawn when neither is present.
// `spawned_by_me` gates every failure-path kill, so a reused server is
// never terminated.
let mut spawned_by_me = false;
let mut child_running = false;
if let Ok(mut guard) = state.child.lock() {
// try_wait: Ok(None) = still running. Exited or unwaitable children
// are treated as gone so a fresh server is spawned below.
child_running = guard
.as_mut()
.map(|child| child.try_wait().map(|st| st.is_none()).unwrap_or(false))
.unwrap_or(false);
}
if !child_running && !kimi_web_alive(Duration::from_millis(800)).await {
let mut cmd = std::process::Command::new("kimi");
cmd.args(["web", "--no-open", "--port", &KIMI_WEB_PORT.to_string()]);
#[cfg(windows)]
@@ -1091,6 +1113,7 @@ pub async fn open_kimi_web_embedded(
format!("failed to start `kimi web`: {e}")
}
})?;
spawned_by_me = true;
if let Ok(mut guard) = state.child.lock() {
*guard = Some(child);
}
@@ -1098,37 +1121,68 @@ pub async fn open_kimi_web_embedded(
// Wait for the server to come up (fresh start takes a moment).
if !kimi_web_alive(Duration::from_secs(8)).await {
if spawned_by_me {
kill_spawned_kimi_web(&state);
}
return Err(format!("kimi web did not come up within 8s ({KIMI_WEB_ORIGIN})"));
}
// Create the window on the main thread (required on macOS).
// Build the URL, then create the window on the main thread (required on
// macOS). The build result is awaited while still holding `launch_lock`,
// so a concurrent second invocation blocks here and then lands on the
// singleton re-check above once the window exists.
let url: tauri::Url = match kimi_web_url().parse() {
Ok(u) => u,
Err(_) => return Err("invalid kimi web url".to_string()),
};
let app = app.clone();
tauri::async_runtime::spawn(async move {
let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
let builder_app = app.clone();
let _ = app.run_on_main_thread(move || {
match tauri::WebviewWindowBuilder::new(
&builder_app,
app.run_on_main_thread(move || {
let result = build_kimi_web_window(&builder_app, &url);
let _ = tx.send(result);
})
.map_err(|e| format!("failed to queue window creation: {e}"))?;
match rx.await {
Ok(result) => {
if let Err(e) = result {
// Window creation failed (e.g. label already taken): only stop
// the server when this invocation spawned it.
if spawned_by_me {
kill_spawned_kimi_web(&state);
}
return Err(e);
}
Ok(())
}
// Sender dropped without a result (app shutting down, build never
// ran): never kill the server — a later invocation may reuse it.
Err(_) => Ok(()),
}
}
/// Build the embedded WebUI window on the main thread and track it for the
/// singleton check. On success, wires close/destroy events to forget the
/// window and stop the `kimi web` server this app spawned (reused servers are
/// untouched).
fn build_kimi_web_window(app: &tauri::AppHandle, url: &tauri::Url) -> Result<(), String> {
let window = tauri::WebviewWindowBuilder::new(
app,
KIMI_WEB_WINDOW_LABEL,
tauri::WebviewUrl::External(url),
tauri::WebviewUrl::External(url.clone()),
)
.title("Kimi Code WebUI")
.inner_size(1100.0, 750.0)
.resizable(true)
.center()
.build()
{
Ok(window) => {
.map_err(|e| format!("failed to create kimi web window: {e}"))?;
// Track the window for the singleton check.
if let Ok(mut guard) = builder_app.state::<KimiWebState>().window.lock() {
if let Ok(mut guard) = app.state::<KimiWebState>().window.lock() {
*guard = Some(window.clone());
}
// Clean up when the window closes: forget it and stop the
// `kimi web` server we spawned (reused servers are untouched).
// Clean up when the window closes: forget it and stop the `kimi web`
// server we spawned (reused servers are untouched).
let w = window.clone();
window.on_window_event(move |event| match event {
tauri::WindowEvent::Destroyed => {
@@ -1141,15 +1195,6 @@ pub async fn open_kimi_web_embedded(
}
_ => {}
});
}
Err(_) => {
// Window creation failed (e.g. rapid double-click racing
// the singleton check): don't leak the server we spawned.
kill_spawned_kimi_web(&builder_app.state::<KimiWebState>());
}
}
});
});
Ok(())
}
/// Preference for Linux is AppImage (portable, no install). If the preferred
+14 -4
View File
@@ -162,6 +162,8 @@ export function SubagentSettingsPage({
const [poolErrors, setPoolErrors] = useState<PoolValidationError[]>([]);
/** Local draft for the "add pool entry" select (reset after each pick). */
const [addSelection, setAddSelection] = useState("");
/** WebUI-open button in flight; disables both buttons while non-null. */
const [webuiBusy, setWebuiBusy] = useState<"embedded" | "browser" | null>(null);
useEffect(() => {
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
@@ -371,29 +373,37 @@ export function SubagentSettingsPage({
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={webuiBusy !== null}
onClick={async () => {
setWebuiBusy("embedded");
try {
await invoke("open_kimi_web_embedded");
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
} finally {
setWebuiBusy(null);
}
}}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("openWebUIEmbedded")}
{webuiBusy === "embedded" ? t("webuiOpening") : t("openWebUIEmbedded")}
</button>
<button
type="button"
disabled={webuiBusy !== null}
onClick={async () => {
setWebuiBusy("browser");
try {
await invoke("open_kimi_web");
} catch (err) {
alert(err instanceof Error ? err.message : String(err));
} finally {
setWebuiBusy(null);
}
}}
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("openWebUIBrowser")}
{webuiBusy === "browser" ? t("webuiOpening") : t("openWebUIBrowser")}
</button>
</div>
</Card>
+1
View File
@@ -528,6 +528,7 @@ export const enTranslations: Record<TranslationKey, string> = {
"Open the new Web UI in an independent in-app window, or in your system browser (built into kimi-code 0.33+). Requires the kimi CLI on PATH.",
openWebUIEmbedded: "Open in App",
openWebUIBrowser: "Open in Browser",
webuiOpening: "Opening...",
// Plugin marketplace
pluginMarketplace: "Plugin Marketplace",
+1
View File
@@ -520,6 +520,7 @@ export const zhTranslations = {
"以独立窗口在应用内打开新版 Web 界面,也可以选择在系统浏览器打开(kimi-code 0.33+ 内置 code-app;需要 kimi 命令在 PATH 中)。",
openWebUIEmbedded: "在应用内打开",
openWebUIBrowser: "在浏览器打开",
webuiOpening: "打开中...",
// Plugin marketplace
pluginMarketplace: "插件市场",