fix(gateway): handle Windows OpenClaw process exit error during in-process restarts (#794)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
122
tests/unit/gateway-manager-restart-recovery.test.ts
Normal file
122
tests/unit/gateway-manager-restart-recovery.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/tmp',
|
||||
isPackaged: false,
|
||||
},
|
||||
utilityProcess: {
|
||||
fork: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('GatewayManager restart recovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-08T00:00:00.000Z'));
|
||||
});
|
||||
|
||||
it('re-enables auto-reconnect when start() fails during restart', async () => {
|
||||
const { GatewayManager } = await import('@electron/gateway/manager');
|
||||
const manager = new GatewayManager();
|
||||
|
||||
// Expose private members for testing
|
||||
const internals = manager as unknown as {
|
||||
shouldReconnect: boolean;
|
||||
status: { state: string; port: number };
|
||||
startLock: boolean;
|
||||
reconnectTimer: NodeJS.Timeout | null;
|
||||
restartInFlight: Promise<void> | null;
|
||||
scheduleReconnect: () => void;
|
||||
stop: () => Promise<void>;
|
||||
start: () => Promise<void>;
|
||||
};
|
||||
|
||||
// Set the manager into a state where restart can proceed:
|
||||
// - state must not be 'starting' or 'reconnecting' (would defer restart)
|
||||
// - startLock must be false
|
||||
internals.status = { state: 'running', port: 18789 };
|
||||
internals.startLock = false;
|
||||
internals.shouldReconnect = true;
|
||||
|
||||
// Mock stop to just reset flags (simulates normal stop)
|
||||
vi.spyOn(manager, 'stop').mockImplementation(async () => {
|
||||
internals.shouldReconnect = false;
|
||||
internals.status = { state: 'stopped', port: 18789 };
|
||||
});
|
||||
|
||||
// Mock start to fail (simulates the race condition where gateway
|
||||
// is reachable but not attachable after in-process restart)
|
||||
vi.spyOn(manager, 'start').mockRejectedValue(
|
||||
new Error('WebSocket closed before handshake: unknown'),
|
||||
);
|
||||
|
||||
// Spy on scheduleReconnect
|
||||
const scheduleReconnectSpy = vi.spyOn(
|
||||
internals as unknown as { scheduleReconnect: () => void },
|
||||
'scheduleReconnect',
|
||||
);
|
||||
|
||||
// Perform the restart - it should throw because start() fails
|
||||
await expect(manager.restart()).rejects.toThrow(
|
||||
'WebSocket closed before handshake: unknown',
|
||||
);
|
||||
|
||||
// KEY ASSERTION: After start() fails in restart(), shouldReconnect
|
||||
// must be re-enabled so the gateway can self-heal
|
||||
expect(internals.shouldReconnect).toBe(true);
|
||||
expect(scheduleReconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not schedule extra reconnect when restart succeeds', async () => {
|
||||
const { GatewayManager } = await import('@electron/gateway/manager');
|
||||
const manager = new GatewayManager();
|
||||
|
||||
const internals = manager as unknown as {
|
||||
shouldReconnect: boolean;
|
||||
status: { state: string; port: number };
|
||||
startLock: boolean;
|
||||
reconnectTimer: NodeJS.Timeout | null;
|
||||
restartInFlight: Promise<void> | null;
|
||||
scheduleReconnect: () => void;
|
||||
};
|
||||
|
||||
internals.status = { state: 'running', port: 18789 };
|
||||
internals.startLock = false;
|
||||
internals.shouldReconnect = true;
|
||||
|
||||
// Mock stop to reset flags
|
||||
vi.spyOn(manager, 'stop').mockImplementation(async () => {
|
||||
internals.shouldReconnect = false;
|
||||
internals.status = { state: 'stopped', port: 18789 };
|
||||
});
|
||||
|
||||
// Mock start to succeed
|
||||
vi.spyOn(manager, 'start').mockImplementation(async () => {
|
||||
internals.shouldReconnect = true;
|
||||
internals.status = { state: 'running', port: 18789 };
|
||||
});
|
||||
|
||||
const scheduleReconnectSpy = vi.spyOn(
|
||||
internals as unknown as { scheduleReconnect: () => void },
|
||||
'scheduleReconnect',
|
||||
);
|
||||
|
||||
await manager.restart();
|
||||
|
||||
// scheduleReconnect should NOT have been called by the catch block
|
||||
// (it may be called from other paths, but not the restart-recovery catch)
|
||||
expect(scheduleReconnectSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user