fix(gateway): debounce restart with single-flight queue (#248)

Root cause for #243 / #244 / #240: model edits trigger
api.restartGateway() with only 300ms debounce. Fast consecutive
edits stack up restart calls, creating zombie Gateway processes,
failed restarts, and CPU fan spikes.

Layer A (frontend):
- New src/lib/gateway-restart-queue.js: 3s debounce + single-flight
  lock + reschedule on in-flight request
- Refactor src/pages/models.js doAutoSave: write config immediately,
  schedule restart via queue with 'Apply now' toast button
- Subscribe to queue state for unified success/failure toast
- Add i18n: models.configQueued, models.applyNow

Layer B (backend):
- src-tauri/src/commands/config.rs: wrap restart_gateway /
  reload_gateway with tokio::sync::Mutex + 2s cooldown
- Cargo.toml: add tokio 'sync' feature
- scripts/dev-api.js: same guard for Web mode (inflight promise
  reuse + 2s cooldown)

Effects:
- 10 rapid edits within 3s -> 1 restart (was 10+ with races)
- Backend serializes concurrent restart calls, no zombie spawns
- User sees single 'Apply now' toast instead of restart storm

Refs #243 #244 #240
This commit is contained in:
晴天
2026-04-24 19:35:39 +08:00
committed by GitHub
parent 66e57adab0
commit 5235853373
6 changed files with 273 additions and 42 deletions

View File

@@ -2225,6 +2225,43 @@ function triggerGatewayReloadNonBlocking(reason) {
}, 0)
}
// Gateway 重启的单飞行锁 + 2s 冷却(配合前端 gateway-restart-queue.js 的 3s 防抖)
// 避免 issue #243 / #240前端穿透节流时后端也能合并重复请求
let _gwRestartInflight = null
let _gwRestartLastFinishedAt = 0
const GW_RESTART_COOLDOWN_MS = 2000
async function guardedGatewayRestart(source = 'unknown') {
if (process.env.DISABLE_GATEWAY_SPAWN === '1' || process.env.DISABLE_GATEWAY_SPAWN === 'true') {
throw new Error('本地 Gateway 启动已禁用DISABLE_GATEWAY_SPAWN=1')
}
if (!isMac && !isLinux) {
throw new Error('Windows 请使用 Tauri 桌面应用')
}
// 进行中的调用:复用同一个 Promise不重复执行
if (_gwRestartInflight) {
return _gwRestartInflight
}
// 冷却期:刚重启完 2 秒内直接返回合并提示
if (Date.now() - _gwRestartLastFinishedAt < GW_RESTART_COOLDOWN_MS) {
return 'Gateway 刚重启过,本次请求已合并(冷却中)'
}
_gwRestartInflight = (async () => {
try {
await handlers.restart_service({ label: 'ai.openclaw.gateway' })
return 'Gateway 已重启'
} finally {
_gwRestartLastFinishedAt = Date.now()
_gwRestartInflight = null
}
})()
return _gwRestartInflight
}
// === macOS 服务管理 ===
function macCheckService(label) {
@@ -3235,25 +3272,11 @@ const handlers = {
},
async reload_gateway() {
if (process.env.DISABLE_GATEWAY_SPAWN === '1' || process.env.DISABLE_GATEWAY_SPAWN === 'true') {
throw new Error('本地 Gateway 启动已禁用DISABLE_GATEWAY_SPAWN=1')
}
if (!isMac && !isLinux) {
throw new Error('Windows 请使用 Tauri 桌面应用')
}
await handlers.restart_service({ label: 'ai.openclaw.gateway' })
return 'Gateway 已重启'
return guardedGatewayRestart('reload_gateway')
},
async restart_gateway() {
if (process.env.DISABLE_GATEWAY_SPAWN === '1' || process.env.DISABLE_GATEWAY_SPAWN === 'true') {
throw new Error('本地 Gateway 启动已禁用DISABLE_GATEWAY_SPAWN=1')
}
if (!isMac && !isLinux) {
throw new Error('Windows 请使用 Tauri 桌面应用')
}
await handlers.restart_service({ label: 'ai.openclaw.gateway' })
return 'Gateway 已重启'
return guardedGatewayRestart('restart_gateway')
},
// === 消息渠道管理 ===