Add channel health diagnostics and gateway recovery fixes (#855)

This commit is contained in:
Lingxuan Zuo
2026-04-15 13:51:02 +08:00
committed by GitHub
Unverified
parent 6acd8acf5a
commit 1f39d1a8a7
22 changed files with 1868 additions and 52 deletions

View File

@@ -11,12 +11,22 @@ vi.mock('electron', () => ({
}));
describe('GatewayManager heartbeat recovery', () => {
const originalPlatform = process.platform;
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-19T00:00:00.000Z'));
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('logs warning but does NOT terminate socket after consecutive heartbeat misses', async () => {
afterEach(() => {
vi.useRealTimers();
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('restarts after consecutive heartbeat misses reach threshold', async () => {
const { GatewayManager } = await import('@electron/gateway/manager');
const manager = new GatewayManager();
@@ -33,20 +43,20 @@ describe('GatewayManager heartbeat recovery', () => {
state: 'running',
port: 18789,
};
const restartSpy = vi.spyOn(manager, 'restart').mockResolvedValue();
(manager as unknown as { startPing: () => void }).startPing();
vi.advanceTimersByTime(120_000);
expect(ws.ping).toHaveBeenCalledTimes(3);
// Heartbeat timeout is now observability-only — socket should NOT be terminated.
// Process liveness is detected via child.on('exit'), socket disconnects via ws.on('close').
expect(ws.terminate).not.toHaveBeenCalled();
expect(restartSpy).toHaveBeenCalledTimes(1);
(manager as unknown as { connectionMonitor: { clear: () => void } }).connectionMonitor.clear();
});
it('does not terminate when heartbeat is recovered by incoming messages', async () => {
it('does not restart when heartbeat is recovered by incoming messages', async () => {
const { GatewayManager } = await import('@electron/gateway/manager');
const manager = new GatewayManager();
@@ -63,6 +73,7 @@ describe('GatewayManager heartbeat recovery', () => {
state: 'running',
port: 18789,
};
const restartSpy = vi.spyOn(manager, 'restart').mockResolvedValue();
(manager as unknown as { startPing: () => void }).startPing();
@@ -75,6 +86,65 @@ describe('GatewayManager heartbeat recovery', () => {
vi.advanceTimersByTime(30_000); // miss #2 + ping #5
expect(ws.terminate).not.toHaveBeenCalled();
expect(restartSpy).not.toHaveBeenCalled();
(manager as unknown as { connectionMonitor: { clear: () => void } }).connectionMonitor.clear();
});
it('skips heartbeat recovery when auto-reconnect is disabled', async () => {
const { GatewayManager } = await import('@electron/gateway/manager');
const manager = new GatewayManager();
const ws = {
readyState: 1,
ping: vi.fn(),
terminate: vi.fn(),
on: vi.fn(),
};
(manager as unknown as { ws: typeof ws }).ws = ws;
(manager as unknown as { shouldReconnect: boolean }).shouldReconnect = false;
(manager as unknown as { status: { state: string; port: number } }).status = {
state: 'running',
port: 18789,
};
const restartSpy = vi.spyOn(manager, 'restart').mockResolvedValue();
(manager as unknown as { startPing: () => void }).startPing();
vi.advanceTimersByTime(120_000);
expect(restartSpy).not.toHaveBeenCalled();
(manager as unknown as { connectionMonitor: { clear: () => void } }).connectionMonitor.clear();
});
it('keeps heartbeat recovery disabled on windows', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
const { GatewayManager } = await import('@electron/gateway/manager');
const manager = new GatewayManager();
const ws = {
readyState: 1,
ping: vi.fn(),
terminate: vi.fn(),
on: vi.fn(),
};
(manager as unknown as { ws: typeof ws }).ws = ws;
(manager as unknown as { shouldReconnect: boolean }).shouldReconnect = true;
(manager as unknown as { status: { state: string; port: number } }).status = {
state: 'running',
port: 18789,
};
const restartSpy = vi.spyOn(manager, 'restart').mockResolvedValue();
(manager as unknown as { startPing: () => void }).startPing();
vi.advanceTimersByTime(400_000);
expect(restartSpy).not.toHaveBeenCalled();
(manager as unknown as { connectionMonitor: { clear: () => void } }).connectionMonitor.clear();
});