fix(webui): 修复首次打开慢时多次点击导致的竞态报错
open_kimi_web_embedded 加 launch_lock 串行化并发调用,拿锁后重查单例; oneshot 在锁内等待主线程建窗完成,封死命令返回后的残留空档。 spawned_by_me 门控失败路径的 server 清理,复用的 server 不再被误杀; spawn 前用 try_wait 检查已有子进程避免覆盖泄漏。前端两个打开按钮加 busy 态防重入(打开中...)。
This commit is contained in:
1 parent
8b597496b3
commit
6a7c8f5393
4 files changed
+106
-49
No files matched your search
+90
-45
@@ -1014,6 +1014,9 @@ pub struct KimiWebState {
|
|||||||
window: Mutex<Option<tauri::WebviewWindow>>,
|
window: Mutex<Option<tauri::WebviewWindow>>,
|
||||||
/// The `kimi web` child spawned by this app (None when reusing a server).
|
/// The `kimi web` child spawned by this app (None when reusing a server).
|
||||||
child: Mutex<Option<std::process::Child>>,
|
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;
|
const KIMI_WEB_PORT: u16 = 58627;
|
||||||
@@ -1064,7 +1067,13 @@ pub async fn open_kimi_web_embedded(
|
|||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
state: tauri::State<'_, KimiWebState>,
|
state: tauri::State<'_, KimiWebState>,
|
||||||
) -> Result<(), String> {
|
) -> 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 Ok(guard) = state.window.lock() {
|
||||||
if let Some(w) = guard.as_ref() {
|
if let Some(w) = guard.as_ref() {
|
||||||
if w.is_visible().unwrap_or(false) {
|
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`.
|
// Reuse the server this app spawned earlier (still running), or a server
|
||||||
if !kimi_web_alive(Duration::from_millis(800)).await {
|
// 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");
|
let mut cmd = std::process::Command::new("kimi");
|
||||||
cmd.args(["web", "--no-open", "--port", &KIMI_WEB_PORT.to_string()]);
|
cmd.args(["web", "--no-open", "--port", &KIMI_WEB_PORT.to_string()]);
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -1091,6 +1113,7 @@ pub async fn open_kimi_web_embedded(
|
|||||||
format!("failed to start `kimi web`: {e}")
|
format!("failed to start `kimi web`: {e}")
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
spawned_by_me = true;
|
||||||
if let Ok(mut guard) = state.child.lock() {
|
if let Ok(mut guard) = state.child.lock() {
|
||||||
*guard = Some(child);
|
*guard = Some(child);
|
||||||
}
|
}
|
||||||
@@ -1098,57 +1121,79 @@ pub async fn open_kimi_web_embedded(
|
|||||||
|
|
||||||
// Wait for the server to come up (fresh start takes a moment).
|
// Wait for the server to come up (fresh start takes a moment).
|
||||||
if !kimi_web_alive(Duration::from_secs(8)).await {
|
if !kimi_web_alive(Duration::from_secs(8)).await {
|
||||||
kill_spawned_kimi_web(&state);
|
if spawned_by_me {
|
||||||
|
kill_spawned_kimi_web(&state);
|
||||||
|
}
|
||||||
return Err(format!("kimi web did not come up within 8s ({KIMI_WEB_ORIGIN})"));
|
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() {
|
let url: tauri::Url = match kimi_web_url().parse() {
|
||||||
Ok(u) => u,
|
Ok(u) => u,
|
||||||
Err(_) => return Err("invalid kimi web url".to_string()),
|
Err(_) => return Err("invalid kimi web url".to_string()),
|
||||||
};
|
};
|
||||||
let app = app.clone();
|
let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
|
||||||
tauri::async_runtime::spawn(async move {
|
let builder_app = app.clone();
|
||||||
let builder_app = app.clone();
|
app.run_on_main_thread(move || {
|
||||||
let _ = app.run_on_main_thread(move || {
|
let result = build_kimi_web_window(&builder_app, &url);
|
||||||
match tauri::WebviewWindowBuilder::new(
|
let _ = tx.send(result);
|
||||||
&builder_app,
|
})
|
||||||
KIMI_WEB_WINDOW_LABEL,
|
.map_err(|e| format!("failed to queue window creation: {e}"))?;
|
||||||
tauri::WebviewUrl::External(url),
|
match rx.await {
|
||||||
)
|
Ok(result) => {
|
||||||
.title("Kimi Code WebUI")
|
if let Err(e) = result {
|
||||||
.inner_size(1100.0, 750.0)
|
// Window creation failed (e.g. label already taken): only stop
|
||||||
.resizable(true)
|
// the server when this invocation spawned it.
|
||||||
.center()
|
if spawned_by_me {
|
||||||
.build()
|
kill_spawned_kimi_web(&state);
|
||||||
{
|
|
||||||
Ok(window) => {
|
|
||||||
// Track the window for the singleton check.
|
|
||||||
if let Ok(mut guard) = builder_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).
|
|
||||||
let w = window.clone();
|
|
||||||
window.on_window_event(move |event| match event {
|
|
||||||
tauri::WindowEvent::Destroyed => {
|
|
||||||
let app = w.app_handle().clone();
|
|
||||||
let state = app.state::<KimiWebState>();
|
|
||||||
if let Ok(mut guard) = state.window.lock() {
|
|
||||||
*guard = None;
|
|
||||||
}
|
|
||||||
kill_spawned_kimi_web(&state);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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>());
|
|
||||||
}
|
}
|
||||||
|
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.clone()),
|
||||||
|
)
|
||||||
|
.title("Kimi Code WebUI")
|
||||||
|
.inner_size(1100.0, 750.0)
|
||||||
|
.resizable(true)
|
||||||
|
.center()
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("failed to create kimi web window: {e}"))?;
|
||||||
|
|
||||||
|
// Track the window for the singleton check.
|
||||||
|
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).
|
||||||
|
let w = window.clone();
|
||||||
|
window.on_window_event(move |event| match event {
|
||||||
|
tauri::WindowEvent::Destroyed => {
|
||||||
|
let app = w.app_handle().clone();
|
||||||
|
let state = app.state::<KimiWebState>();
|
||||||
|
if let Ok(mut guard) = state.window.lock() {
|
||||||
|
*guard = None;
|
||||||
|
}
|
||||||
|
kill_spawned_kimi_web(&state);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ export function SubagentSettingsPage({
|
|||||||
const [poolErrors, setPoolErrors] = useState<PoolValidationError[]>([]);
|
const [poolErrors, setPoolErrors] = useState<PoolValidationError[]>([]);
|
||||||
/** Local draft for the "add pool entry" select (reset after each pick). */
|
/** Local draft for the "add pool entry" select (reset after each pick). */
|
||||||
const [addSelection, setAddSelection] = useState("");
|
const [addSelection, setAddSelection] = useState("");
|
||||||
|
/** WebUI-open button in flight; disables both buttons while non-null. */
|
||||||
|
const [webuiBusy, setWebuiBusy] = useState<"embedded" | "browser" | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
|
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
|
||||||
@@ -371,29 +373,37 @@ export function SubagentSettingsPage({
|
|||||||
<div className="mt-3 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={webuiBusy !== null}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
setWebuiBusy("embedded");
|
||||||
try {
|
try {
|
||||||
await invoke("open_kimi_web_embedded");
|
await invoke("open_kimi_web_embedded");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : String(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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={webuiBusy !== null}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
setWebuiBusy("browser");
|
||||||
try {
|
try {
|
||||||
await invoke("open_kimi_web");
|
await invoke("open_kimi_web");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : String(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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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.",
|
"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",
|
openWebUIEmbedded: "Open in App",
|
||||||
openWebUIBrowser: "Open in Browser",
|
openWebUIBrowser: "Open in Browser",
|
||||||
|
webuiOpening: "Opening...",
|
||||||
|
|
||||||
// Plugin marketplace
|
// Plugin marketplace
|
||||||
pluginMarketplace: "Plugin Marketplace",
|
pluginMarketplace: "Plugin Marketplace",
|
||||||
|
|||||||
@@ -520,6 +520,7 @@ export const zhTranslations = {
|
|||||||
"以独立窗口在应用内打开新版 Web 界面,也可以选择在系统浏览器打开(kimi-code 0.33+ 内置 code-app;需要 kimi 命令在 PATH 中)。",
|
"以独立窗口在应用内打开新版 Web 界面,也可以选择在系统浏览器打开(kimi-code 0.33+ 内置 code-app;需要 kimi 命令在 PATH 中)。",
|
||||||
openWebUIEmbedded: "在应用内打开",
|
openWebUIEmbedded: "在应用内打开",
|
||||||
openWebUIBrowser: "在浏览器打开",
|
openWebUIBrowser: "在浏览器打开",
|
||||||
|
webuiOpening: "打开中...",
|
||||||
|
|
||||||
// Plugin marketplace
|
// Plugin marketplace
|
||||||
pluginMarketplace: "插件市场",
|
pluginMarketplace: "插件市场",
|
||||||
|
|||||||
Reference in new issue
Block a user