fix(gateway): harden heartbeat timeout recovery to avoid reconnect flapping (#588)

Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
Lingxuan Zuo
2026-03-19 14:31:08 +08:00
committed by GitHub
Unverified
parent 8cca9af773
commit 8029b507ba
4 changed files with 241 additions and 41 deletions

View File

@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GatewayConnectionMonitor } from '@electron/gateway/connection-monitor';
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp',
getVersion: () => '0.0.0-test',
isPackaged: false,
},
utilityProcess: {
fork: vi.fn(),
},
}));
describe('GatewayConnectionMonitor heartbeat', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-19T00:00:00.000Z'));
});
it('terminates only after consecutive heartbeat misses reach threshold', () => {
const monitor = new GatewayConnectionMonitor();
const sendPing = vi.fn();
const onHeartbeatTimeout = vi.fn();
monitor.startPing({
sendPing,
onHeartbeatTimeout,
intervalMs: 100,
timeoutMs: 50,
maxConsecutiveMisses: 3,
});
vi.advanceTimersByTime(100); // send ping #1
vi.advanceTimersByTime(100); // miss #1, send ping #2
vi.advanceTimersByTime(100); // miss #2, send ping #3
expect(onHeartbeatTimeout).not.toHaveBeenCalled();
vi.advanceTimersByTime(100); // miss #3 -> timeout callback
expect(onHeartbeatTimeout).toHaveBeenCalledTimes(1);
expect(onHeartbeatTimeout).toHaveBeenCalledWith({ consecutiveMisses: 3, timeoutMs: 50 });
expect(sendPing).toHaveBeenCalledTimes(3);
});
it('resets miss counter when alive signal is received', () => {
const monitor = new GatewayConnectionMonitor();
const sendPing = vi.fn();
const onHeartbeatTimeout = vi.fn();
monitor.startPing({
sendPing,
onHeartbeatTimeout,
intervalMs: 100,
timeoutMs: 50,
maxConsecutiveMisses: 2,
});
vi.advanceTimersByTime(100); // send ping #1
vi.advanceTimersByTime(100); // miss #1, send ping #2
expect(monitor.getConsecutiveMisses()).toBe(1);
monitor.markAlive('pong');
expect(monitor.getConsecutiveMisses()).toBe(0);
vi.advanceTimersByTime(100); // send ping #3
vi.advanceTimersByTime(100); // miss #1 again (reset confirmed)
expect(monitor.getConsecutiveMisses()).toBe(1);
expect(onHeartbeatTimeout).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp',
isPackaged: false,
},
utilityProcess: {
fork: vi.fn(),
},
}));
describe('GatewayManager heartbeat recovery', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-19T00:00:00.000Z'));
});
it('terminates stale socket only after 3 consecutive heartbeat misses', async () => {
const { GatewayManager } = await import('@electron/gateway/manager');
const manager = new GatewayManager();
const ws = {
readyState: 1, // WebSocket.OPEN
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,
};
(manager as unknown as { startPing: () => void }).startPing();
vi.advanceTimersByTime(120_000);
expect(ws.ping).toHaveBeenCalledTimes(3);
expect(ws.terminate).toHaveBeenCalledTimes(1);
(manager as unknown as { connectionMonitor: { clear: () => void } }).connectionMonitor.clear();
});
it('does not terminate when heartbeat is recovered by incoming messages', async () => {
const { GatewayManager } = await import('@electron/gateway/manager');
const manager = new GatewayManager();
const ws = {
readyState: 1, // WebSocket.OPEN
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,
};
(manager as unknown as { startPing: () => void }).startPing();
vi.advanceTimersByTime(30_000); // ping #1
vi.advanceTimersByTime(30_000); // miss #1 + ping #2
(manager as unknown as { handleMessage: (message: unknown) => void }).handleMessage('alive');
vi.advanceTimersByTime(30_000); // recovered, ping #3
vi.advanceTimersByTime(30_000); // miss #1 + ping #4
vi.advanceTimersByTime(30_000); // miss #2 + ping #5
expect(ws.terminate).not.toHaveBeenCalled();
(manager as unknown as { connectionMonitor: { clear: () => void } }).connectionMonitor.clear();
});
});