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

@@ -8,6 +8,7 @@ import { showModal, showConfirm } from '../components/modal.js'
import { icon, statusIcon } from '../lib/icons.js'
import { API_TYPES, PROVIDER_PRESETS, QTCOOL, MODEL_PRESETS, fetchQtcoolModels } from '../lib/model-presets.js'
import { t } from '../lib/i18n.js'
import { scheduleGatewayRestart, fireRestartNow, cancelPendingRestart, onRestartState } from '../lib/gateway-restart-queue.js'
export async function render() {
const page = document.createElement('div')
@@ -351,7 +352,8 @@ async function undo(page, state) {
toast(t('models.undone'), 'info')
}
// 自动保存(防抖 300ms
// 自动保存(防抖 300ms+ Gateway 重启队列3s 防抖 + 单飞行锁)
// 解决 issue #243 / #244 / #240快速连续编辑不再触发多次重启
let _saveTimer = null
let _batchTestAbort = null // 批量测试终止控制器
@@ -359,6 +361,7 @@ export function cleanup() {
clearTimeout(_saveTimer)
_saveTimer = null
if (_batchTestAbort) { _batchTestAbort.abort = true; _batchTestAbort = null }
cancelPendingRestart()
}
function autoSave(state) {
clearTimeout(_saveTimer)
@@ -430,33 +433,46 @@ async function doAutoSave(state) {
normalizeProviderUrls(state.config)
await api.writeOpenclawConfig(state.config)
// 重启 Gateway 使配置生效Gateway 不支持 SIGHUP 热重载)
toast(t('models.configSavedRestarting'), 'info')
try {
await api.restartGateway()
toast(t('models.configEffective'), 'success')
} catch (e) {
// 重启失败时提供手动重试按钮
const restartBtn = document.createElement('button')
restartBtn.className = 'btn btn-sm btn-primary'
restartBtn.textContent = t('models.retryRestart')
restartBtn.style.marginLeft = '8px'
restartBtn.onclick = async () => {
try {
toast(t('models.restarting'), 'info')
await api.restartGateway()
toast(t('models.restartOk'), 'success')
} catch (e2) {
toast(t('models.restartFailed') + ': ' + e2.message, 'error')
}
}
toast(t('models.configSavedGwFailed') + ': ' + e.message, 'warning', { action: restartBtn })
}
// 配置已写入。使用 3s 防抖 + 单飞行锁排队重启,避免快速连续编辑触发多次重启。
showRestartPendingToast()
scheduleGatewayRestart({ reason: 'models-page' })
} catch (e) {
toast(t('models.autoSaveFailed') + ': ' + e, 'error')
}
}
function showRestartPendingToast() {
const applyNow = document.createElement('button')
applyNow.className = 'btn btn-sm btn-primary'
applyNow.textContent = t('models.applyNow')
applyNow.style.marginLeft = '8px'
applyNow.onclick = () => fireRestartNow()
toast(t('models.configQueued'), 'info', { action: applyNow, duration: 3500 })
}
/**
* 处理重启队列事件并展示 toast。监听在模块级别全生命周期生效。
* - succeeded → 成功提示
* - failed → 失败提示 + 重试按钮
*/
function handleRestartState(ev) {
if (ev.event === 'succeeded') {
toast(t('models.configEffective'), 'success')
} else if (ev.event === 'failed') {
const retryBtn = document.createElement('button')
retryBtn.className = 'btn btn-sm btn-primary'
retryBtn.textContent = t('models.retryRestart')
retryBtn.style.marginLeft = '8px'
retryBtn.onclick = () => scheduleGatewayRestart({ delay: 0, reason: 'retry' })
toast(t('models.configSavedGwFailed') + ': ' + ev.error, 'warning', { action: retryBtn, duration: 6000 })
}
}
let _restartStateOff = null
if (typeof window !== 'undefined' && !_restartStateOff) {
_restartStateOff = onRestartState(handleRestartState)
}
// 更新撤销按钮状态
function updateUndoBtn(page, state) {
const btn = page.querySelector('#btn-undo')