From be800f6cfc14c7b546066913d862cbe4927401e3 Mon Sep 17 00:00:00 2001 From: Haze <709547807@qq.com> Date: Wed, 4 Mar 2026 21:45:44 +0800 Subject: [PATCH] fix(channel): Feishu channel gateway wont restart after config (#291) Co-authored-by: Cursor Agent Co-authored-by: Haze --- electron/gateway/manager.ts | 84 ++++++++++++++++++++++- electron/gateway/process-policy.ts | 33 +++++++++ electron/main/ipc-handlers.ts | 16 +++-- src/pages/Chat/ChatInput.tsx | 2 +- tests/unit/gateway-process-policy.test.ts | 60 ++++++++++++++++ 5 files changed, 186 insertions(+), 9 deletions(-) diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 52ea3624f..8cc9ea517 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -33,16 +33,19 @@ import { buildProxyEnv, resolveProxySettings } from '../utils/proxy'; import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy'; import { shouldAttemptConfigAutoRepair } from './startup-recovery'; import { + type GatewayLifecycleState, + getDeferredRestartAction, getReconnectSkipReason, isLifecycleSuperseded, nextLifecycleEpoch, + shouldDeferRestart, } from './process-policy'; /** * Gateway connection status */ export interface GatewayStatus { - state: 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting'; + state: GatewayLifecycleState; port: number; pid?: number; uptime?: number; @@ -218,6 +221,8 @@ export class GatewayManager extends EventEmitter { private deviceIdentity: DeviceIdentity | null = null; private restartDebounceTimer: NodeJS.Timeout | null = null; private lifecycleEpoch = 0; + private deferredRestartPending = false; + private restartInFlight: Promise | null = null; constructor(config?: Partial) { super(); @@ -292,6 +297,56 @@ export class GatewayManager extends EventEmitter { } } + private isRestartDeferred(): boolean { + return shouldDeferRestart({ + state: this.status.state, + startLock: this.startLock, + }); + } + + private markDeferredRestart(reason: string): void { + if (!this.deferredRestartPending) { + logger.info( + `Deferring Gateway restart (${reason}) until startup/reconnect settles (state=${this.status.state}, startLock=${this.startLock})` + ); + } else { + logger.debug( + `Gateway restart already deferred; keeping pending request (${reason}, state=${this.status.state}, startLock=${this.startLock})` + ); + } + this.deferredRestartPending = true; + } + + private flushDeferredRestart(trigger: string): void { + const action = getDeferredRestartAction({ + hasPendingRestart: this.deferredRestartPending, + state: this.status.state, + startLock: this.startLock, + shouldReconnect: this.shouldReconnect, + }); + + if (action === 'none') return; + if (action === 'wait') { + logger.debug( + `Deferred Gateway restart still waiting (${trigger}, state=${this.status.state}, startLock=${this.startLock})` + ); + return; + } + + this.deferredRestartPending = false; + if (action === 'drop') { + logger.info( + `Dropping deferred Gateway restart (${trigger}) because lifecycle already recovered (state=${this.status.state}, shouldReconnect=${this.shouldReconnect})` + ); + return; + } + + logger.info(`Executing deferred Gateway restart now (${trigger})`); + void this.restart().catch((error) => { + logger.warn('Deferred Gateway restart failed:', error); + }); + } + /** * Get current Gateway status */ @@ -445,6 +500,7 @@ export class GatewayManager extends EventEmitter { throw error; } finally { this.startLock = false; + this.flushDeferredRestart('start:finally'); } } @@ -521,6 +577,7 @@ export class GatewayManager extends EventEmitter { } this.pendingRequests.clear(); + this.deferredRestartPending = false; this.setStatus({ state: 'stopped', error: undefined, pid: undefined, connectedAt: undefined, uptime: undefined }); } @@ -528,9 +585,29 @@ export class GatewayManager extends EventEmitter { * Restart Gateway process */ async restart(): Promise { + if (this.isRestartDeferred()) { + this.markDeferredRestart('restart'); + return; + } + + if (this.restartInFlight) { + logger.debug('Gateway restart already in progress, joining existing request'); + await this.restartInFlight; + return; + } + logger.debug('Gateway restart requested'); - await this.stop(); - await this.start(); + this.restartInFlight = (async () => { + await this.stop(); + await this.start(); + })(); + + try { + await this.restartInFlight; + } finally { + this.restartInFlight = null; + this.flushDeferredRestart('restart:finally'); + } } /** @@ -1639,6 +1716,7 @@ export class GatewayManager extends EventEmitter { // Log state transitions if (previousState !== this.status.state) { logger.debug(`Gateway state changed: ${previousState} -> ${this.status.state}`); + this.flushDeferredRestart(`status:${previousState}->${this.status.state}`); } } } diff --git a/electron/gateway/process-policy.ts b/electron/gateway/process-policy.ts index ea8b9c222..c693a14c0 100644 --- a/electron/gateway/process-policy.ts +++ b/electron/gateway/process-policy.ts @@ -21,3 +21,36 @@ export function getReconnectSkipReason(context: ReconnectAttemptContext): string } return null; } + +export type GatewayLifecycleState = 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting'; + +export interface RestartDeferralContext { + state: GatewayLifecycleState; + startLock: boolean; +} + +/** + * Restart requests should not interrupt an in-flight startup/reconnect flow. + * Doing so can kill a just-spawned process and leave the manager stopped. + */ +export function shouldDeferRestart(context: RestartDeferralContext): boolean { + return context.startLock || context.state === 'starting' || context.state === 'reconnecting'; +} + +export interface DeferredRestartActionContext extends RestartDeferralContext { + hasPendingRestart: boolean; + shouldReconnect: boolean; +} + +export type DeferredRestartAction = 'none' | 'wait' | 'drop' | 'execute'; + +/** + * Decide what to do with a pending deferred restart once lifecycle changes. + */ +export function getDeferredRestartAction(context: DeferredRestartActionContext): DeferredRestartAction { + if (!context.hasPendingRestart) return 'none'; + if (shouldDeferRestart(context)) return 'wait'; + if (!context.shouldReconnect) return 'drop'; + if (context.state === 'running') return 'drop'; + return 'execute'; +} diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 8109e23e5..8722fa16f 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -138,7 +138,7 @@ export function registerIpcHandlers( registerClawHubHandlers(clawHubService); // OpenClaw handlers - registerOpenClawHandlers(gatewayManager); + registerOpenClawHandlers(); // Provider handlers registerProviderHandlers(gatewayManager); @@ -695,7 +695,7 @@ function registerGatewayHandlers( * OpenClaw-related IPC handlers * For checking package status and channel configuration */ -function registerOpenClawHandlers(gatewayManager: GatewayManager): void { +function registerOpenClawHandlers(): void { async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> { const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk'); const targetManifest = join(targetDir, 'openclaw.plugin.json'); @@ -808,7 +808,9 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void { }; } await saveChannelConfig(channelType, config); - gatewayManager.debouncedRestart(); + logger.info( + `Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); Gateway handles channel config reload/restart internally` + ); return { success: true, pluginInstalled: installResult.installed, @@ -816,8 +818,12 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void { }; } await saveChannelConfig(channelType, config); - // Debounced restart so the gateway picks up the new channel config. - gatewayManager.debouncedRestart(); + // Do not force stop/start here. Recent Gateway builds detect channel config + // changes and perform an internal service restart; forcing another restart + // from Electron can race with reconnect and kill the newly spawned process. + logger.info( + `Skipping app-forced Gateway restart after channel:saveConfig (${channelType}); waiting for Gateway internal channel reload` + ); return { success: true }; } catch (error) { console.error('Failed to save channel config:', error); diff --git a/src/pages/Chat/ChatInput.tsx b/src/pages/Chat/ChatInput.tsx index 8908eec7d..b21f4774c 100644 --- a/src/pages/Chat/ChatInput.tsx +++ b/src/pages/Chat/ChatInput.tsx @@ -94,7 +94,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }: if (!disabled && textareaRef.current) { textareaRef.current.focus(); } - }, []); + }, [disabled]); // ── File staging via native dialog ───────────────────────────── diff --git a/tests/unit/gateway-process-policy.test.ts b/tests/unit/gateway-process-policy.test.ts index 1b7e001cb..04089b20d 100644 --- a/tests/unit/gateway-process-policy.test.ts +++ b/tests/unit/gateway-process-policy.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest'; import { + getDeferredRestartAction, getReconnectSkipReason, isLifecycleSuperseded, nextLifecycleEpoch, + shouldDeferRestart, } from '@electron/gateway/process-policy'; describe('gateway process policy helpers', () => { @@ -49,4 +51,62 @@ describe('gateway process policy helpers', () => { ).toBeNull(); }); }); + + describe('restart deferral policy', () => { + it('defers restart while startup or reconnect is in progress', () => { + expect(shouldDeferRestart({ state: 'starting', startLock: false })).toBe(true); + expect(shouldDeferRestart({ state: 'reconnecting', startLock: false })).toBe(true); + expect(shouldDeferRestart({ state: 'running', startLock: true })).toBe(true); + }); + + it('does not defer restart for stable states when no start lock', () => { + expect(shouldDeferRestart({ state: 'running', startLock: false })).toBe(false); + expect(shouldDeferRestart({ state: 'stopped', startLock: false })).toBe(false); + expect(shouldDeferRestart({ state: 'error', startLock: false })).toBe(false); + }); + + it('drops deferred restart once lifecycle recovers to running', () => { + expect( + getDeferredRestartAction({ + hasPendingRestart: true, + state: 'running', + startLock: false, + shouldReconnect: true, + }) + ).toBe('drop'); + }); + + it('waits deferred restart while lifecycle is still busy', () => { + expect( + getDeferredRestartAction({ + hasPendingRestart: true, + state: 'starting', + startLock: false, + shouldReconnect: true, + }) + ).toBe('wait'); + }); + + it('executes deferred restart when manager is idle and not running', () => { + expect( + getDeferredRestartAction({ + hasPendingRestart: true, + state: 'error', + startLock: false, + shouldReconnect: true, + }) + ).toBe('execute'); + }); + + it('drops deferred restart when reconnect is disabled', () => { + expect( + getDeferredRestartAction({ + hasPendingRestart: true, + state: 'stopped', + startLock: false, + shouldReconnect: false, + }) + ).toBe('drop'); + }); + }); });