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

@@ -0,0 +1,153 @@
/**
* Gateway 重启防抖队列
*
* 解决 issue #243 #244 #240用户快速连续改动配置时之前会触发多次 Gateway 重启
* 导致僵尸进程堆积、风扇狂转、重启失败等问题。
*
* 设计:
* - 默认 3s 空闲防抖:连续编辑时每次重置计时
* - 单飞行锁:前一次重启未完成时,新请求只设"补重启"标记
* - 立即执行入口UI 提供 "立即重载" 按钮跳过倒计时
* - 事件订阅:供顶部状态条 / toast 展示倒计时与结果
*
* 对外 API
* scheduleGatewayRestart({ delay, reason }) // 入队
* fireRestartNow() // 跳过倒计时
* cancelPendingRestart() // 取消
* hasPendingRestart() / isRestartInFlight() // 状态查询
* onRestartState(cb) // 订阅状态变化
*/
import { api } from './tauri-api.js'
const DEFAULT_DELAY_MS = 3000
const RESCHEDULE_DELAY_MS = 500
let _pendingTimer = null
let _scheduledAt = 0
let _scheduledDelay = 0
let _currentReason = ''
let _inflight = false
let _needRerun = false
let _listeners = []
function emit(eventName, detail = {}) {
const payload = {
event: eventName,
reason: _currentReason,
pending: hasPendingRestart(),
inflight: _inflight,
scheduledAt: _scheduledAt,
delay: _scheduledDelay,
...detail,
}
_listeners.forEach(fn => {
try { fn(payload) } catch (_) { /* 忽略订阅方异常 */ }
})
}
/**
* 预约一次 Gateway 重启。多次调用会合并为一次。
* @param {Object} opts
* @param {number} [opts.delay=3000] 空闲多久后触发(毫秒)
* @param {string} [opts.reason='config-change'] 触发原因(用于日志/UI
*/
export function scheduleGatewayRestart(opts = {}) {
const delay = Number.isFinite(opts.delay) ? opts.delay : DEFAULT_DELAY_MS
const reason = opts.reason || 'config-change'
if (_pendingTimer) clearTimeout(_pendingTimer)
_scheduledAt = Date.now()
_scheduledDelay = delay
_currentReason = reason
if (_inflight) {
_needRerun = true
emit('deferred')
return
}
_pendingTimer = setTimeout(runRestart, delay)
emit('scheduled')
}
/**
* 跳过倒计时,立即执行重启。
*/
export function fireRestartNow() {
if (_pendingTimer) {
clearTimeout(_pendingTimer)
_pendingTimer = null
}
if (_inflight) {
_needRerun = true
emit('deferred')
return
}
runRestart()
}
/**
* 取消待执行的重启。用户显式拒绝、页面卸载时调用。
*/
export function cancelPendingRestart() {
if (_pendingTimer) {
clearTimeout(_pendingTimer)
_pendingTimer = null
}
_needRerun = false
_scheduledAt = 0
emit('cancelled')
}
export function hasPendingRestart() {
return _pendingTimer !== null
}
export function isRestartInFlight() {
return _inflight
}
export function getPendingInfo() {
if (!_pendingTimer) return null
const elapsed = Date.now() - _scheduledAt
return {
reason: _currentReason,
delay: _scheduledDelay,
remaining: Math.max(0, _scheduledDelay - elapsed),
}
}
/**
* 订阅重启状态事件。返回取消订阅函数。
* 事件类型:
* - scheduled / deferred / cancelled
* - started / succeeded / failed
*/
export function onRestartState(fn) {
_listeners.push(fn)
return () => {
_listeners = _listeners.filter(cb => cb !== fn)
}
}
async function runRestart() {
_pendingTimer = null
_inflight = true
emit('started')
try {
const result = await api.restartGateway()
emit('succeeded', { result })
} catch (err) {
emit('failed', { error: err?.message ? err.message : String(err) })
} finally {
_inflight = false
}
// 运行期间有新请求 → 稍等 500ms 再跑一次
if (_needRerun) {
_needRerun = false
scheduleGatewayRestart({ delay: RESCHEDULE_DELAY_MS, reason: 'rescheduled' })
}
}

View File

@@ -148,6 +148,8 @@ export default {
saveFailed: _('保存失败', 'Save failed', '儲存失敗', '保存失敗', '저장 실패'),
autoSaveFailed: _('自动保存失败', 'Auto-save failed', '自動儲存失敗'),
configSavedRestarting: _('配置已保存,正在重启 Gateway...', 'Config saved, restarting Gateway...', '設定已儲存,正在重啟 Gateway...'),
configQueued: _('配置已保存,即将重载 Gateway…', 'Config saved. Gateway will reload shortly…', '設定已儲存,即將重載 Gateway…'),
applyNow: _('立即生效', 'Apply now', '立即生效'),
configEffective: _('配置已生效Gateway 已重启', 'Config applied, Gateway restarted', '設定已生效Gateway 已重啟'),
retryRestart: _('重试', 'Retry', '重試'),
restarting: _('正在重启 Gateway...', 'Restarting Gateway...', '正在重啟 Gateway...'),

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')