Stabilize gateway reload/restart behavior and remove doctor --json dependency (#504)

This commit is contained in:
Lingxuan Zuo
2026-03-16 09:47:04 +08:00
committed by GitHub
Unverified
parent 89bda3c7af
commit 7f3408559d
19 changed files with 843 additions and 62 deletions

View File

@@ -102,3 +102,38 @@ describe('channel credential normalization and duplicate checks', () => {
);
});
});
describe('parseDoctorValidationOutput', () => {
it('extracts channel error and warning lines', async () => {
const { parseDoctorValidationOutput } = await import('@electron/utils/channel-config');
const out = parseDoctorValidationOutput(
'feishu',
'feishu error: token invalid\nfeishu warning: fallback enabled\n',
);
expect(out.undetermined).toBe(false);
expect(out.errors).toEqual(['feishu error: token invalid']);
expect(out.warnings).toEqual(['feishu warning: fallback enabled']);
});
it('falls back with hint when output has no channel signal', async () => {
const { parseDoctorValidationOutput } = await import('@electron/utils/channel-config');
const out = parseDoctorValidationOutput('feishu', 'all good, no channel details');
expect(out.undetermined).toBe(true);
expect(out.errors).toEqual([]);
expect(out.warnings.some((w) => w.includes('falling back to local channel config checks'))).toBe(true);
});
it('falls back with hint when output is empty', async () => {
const { parseDoctorValidationOutput } = await import('@electron/utils/channel-config');
const out = parseDoctorValidationOutput('feishu', ' ');
expect(out.undetermined).toBe(true);
expect(out.errors).toEqual([]);
expect(out.warnings.some((w) => w.includes('falling back to local channel config checks'))).toBe(true);
});
});

View File

@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { mockLoadGatewayReloadPolicy } = vi.hoisted(() => ({
mockLoadGatewayReloadPolicy: vi.fn(),
}));
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp',
isPackaged: false,
},
utilityProcess: {
fork: vi.fn(),
},
}));
vi.mock('@electron/gateway/reload-policy', async () => {
const actual = await vi.importActual<typeof import('@electron/gateway/reload-policy')>(
'@electron/gateway/reload-policy',
);
return {
...actual,
loadGatewayReloadPolicy: (...args: unknown[]) => mockLoadGatewayReloadPolicy(...args),
};
});
describe('GatewayManager refreshReloadPolicy', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-15T00:00:00.000Z'));
});
it('deduplicates concurrent refresh calls', async () => {
const { GatewayManager } = await import('@electron/gateway/manager');
let resolveLoad: ((value: { mode: 'reload'; debounceMs: number }) => void) | null = null;
mockLoadGatewayReloadPolicy.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveLoad = resolve;
}),
);
const manager = new GatewayManager();
const refresh = (manager as unknown as { refreshReloadPolicy: (force?: boolean) => Promise<void> })
.refreshReloadPolicy.bind(manager);
const p1 = refresh(true);
const p2 = refresh(true);
expect(mockLoadGatewayReloadPolicy).toHaveBeenCalledTimes(1);
resolveLoad?.({ mode: 'reload', debounceMs: 1300 });
await Promise.all([p1, p2]);
expect((manager as unknown as { reloadPolicy: { mode: string; debounceMs: number } }).reloadPolicy).toEqual({
mode: 'reload',
debounceMs: 1300,
});
});
it('hits TTL cache and skips refresh within window', async () => {
const { GatewayManager } = await import('@electron/gateway/manager');
mockLoadGatewayReloadPolicy.mockResolvedValueOnce({ mode: 'restart', debounceMs: 2200 });
const manager = new GatewayManager();
const refresh = (manager as unknown as { refreshReloadPolicy: (force?: boolean) => Promise<void> })
.refreshReloadPolicy.bind(manager);
await refresh();
expect(mockLoadGatewayReloadPolicy).toHaveBeenCalledTimes(1);
vi.setSystemTime(new Date('2026-03-15T00:00:10.000Z'));
await refresh();
expect(mockLoadGatewayReloadPolicy).toHaveBeenCalledTimes(1);
});
it('refreshes immediately when force=true even within TTL', async () => {
const { GatewayManager } = await import('@electron/gateway/manager');
mockLoadGatewayReloadPolicy
.mockResolvedValueOnce({ mode: 'hybrid', debounceMs: 1200 })
.mockResolvedValueOnce({ mode: 'off', debounceMs: 9000 });
const manager = new GatewayManager();
const refresh = (manager as unknown as { refreshReloadPolicy: (force?: boolean) => Promise<void> })
.refreshReloadPolicy.bind(manager);
await refresh();
expect(mockLoadGatewayReloadPolicy).toHaveBeenCalledTimes(1);
vi.setSystemTime(new Date('2026-03-15T00:00:05.000Z'));
await refresh(true);
expect(mockLoadGatewayReloadPolicy).toHaveBeenCalledTimes(2);
expect((manager as unknown as { reloadPolicy: { mode: string; debounceMs: number } }).reloadPolicy).toEqual({
mode: 'off',
debounceMs: 9000,
});
});
});

View File

@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { mockReadFile } = vi.hoisted(() => ({
mockReadFile: vi.fn(),
}));
vi.mock('node:fs/promises', () => ({
readFile: mockReadFile,
default: {
readFile: mockReadFile,
},
}));
import {
DEFAULT_GATEWAY_RELOAD_POLICY,
loadGatewayReloadPolicy,
parseGatewayReloadPolicy,
} from '@electron/gateway/reload-policy';
describe('parseGatewayReloadPolicy', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns defaults when config is missing', () => {
expect(parseGatewayReloadPolicy(undefined)).toEqual(DEFAULT_GATEWAY_RELOAD_POLICY);
});
it('parses mode and debounce from gateway.reload', () => {
const result = parseGatewayReloadPolicy({
gateway: {
reload: {
mode: 'off',
debounceMs: 3000,
},
},
});
expect(result).toEqual({ mode: 'off', debounceMs: 3000 });
});
it('normalizes invalid mode and debounce bounds', () => {
const negative = parseGatewayReloadPolicy({
gateway: { reload: { mode: 'invalid', debounceMs: -100 } },
});
expect(negative).toEqual({
mode: DEFAULT_GATEWAY_RELOAD_POLICY.mode,
debounceMs: 0,
});
const overMax = parseGatewayReloadPolicy({
gateway: { reload: { mode: 'hybrid', debounceMs: 600_000 } },
});
expect(overMax).toEqual({ mode: 'hybrid', debounceMs: 60_000 });
});
it('falls back to default mode for non-string or unknown mode values', () => {
const unknownString = parseGatewayReloadPolicy({
gateway: { reload: { mode: 'HYBRID', debounceMs: 1200 } },
});
expect(unknownString.mode).toBe(DEFAULT_GATEWAY_RELOAD_POLICY.mode);
const nonString = parseGatewayReloadPolicy({
gateway: { reload: { mode: { value: 'reload' }, debounceMs: 1200 } },
});
expect(nonString.mode).toBe(DEFAULT_GATEWAY_RELOAD_POLICY.mode);
});
it('handles malformed gateway/reload shapes', () => {
const malformedGateway = parseGatewayReloadPolicy({ gateway: 'bad-shape' });
expect(malformedGateway).toEqual(DEFAULT_GATEWAY_RELOAD_POLICY);
const malformedReload = parseGatewayReloadPolicy({
gateway: { reload: ['bad-shape'] },
});
expect(malformedReload).toEqual(DEFAULT_GATEWAY_RELOAD_POLICY);
});
it('normalizes debounce boundary and rounding behavior', () => {
const atMin = parseGatewayReloadPolicy({
gateway: { reload: { mode: 'reload', debounceMs: 0 } },
});
expect(atMin).toEqual({ mode: 'reload', debounceMs: 0 });
const roundsUpToCap = parseGatewayReloadPolicy({
gateway: { reload: { mode: 'reload', debounceMs: 60_000.5 } },
});
expect(roundsUpToCap).toEqual({ mode: 'reload', debounceMs: 60_000 });
const roundsDownAtCap = parseGatewayReloadPolicy({
gateway: { reload: { mode: 'reload', debounceMs: 60_000.4 } },
});
expect(roundsDownAtCap).toEqual({ mode: 'reload', debounceMs: 60_000 });
});
});
describe('loadGatewayReloadPolicy', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns defaults when config read fails', async () => {
mockReadFile.mockRejectedValueOnce(new Error('EACCES'));
await expect(loadGatewayReloadPolicy()).resolves.toEqual(DEFAULT_GATEWAY_RELOAD_POLICY);
expect(mockReadFile).toHaveBeenCalledOnce();
});
it('returns defaults when config JSON is malformed', async () => {
mockReadFile.mockResolvedValueOnce('{');
await expect(loadGatewayReloadPolicy()).resolves.toEqual(DEFAULT_GATEWAY_RELOAD_POLICY);
});
it('returns defaults when config JSON has malformed shape', async () => {
mockReadFile.mockResolvedValueOnce(
JSON.stringify({
gateway: { reload: ['malformed'] },
}),
);
await expect(loadGatewayReloadPolicy()).resolves.toEqual(DEFAULT_GATEWAY_RELOAD_POLICY);
});
it('loads config and applies invalid mode fallback', async () => {
mockReadFile.mockResolvedValueOnce(
JSON.stringify({
gateway: { reload: { mode: 'unknown-mode', debounceMs: 1350 } },
}),
);
await expect(loadGatewayReloadPolicy()).resolves.toEqual({
mode: DEFAULT_GATEWAY_RELOAD_POLICY.mode,
debounceMs: 1350,
});
});
it('loads config and keeps debounce boundary values', async () => {
mockReadFile.mockResolvedValueOnce(
JSON.stringify({
gateway: { reload: { mode: 'restart', debounceMs: 60_000 } },
}),
);
await expect(loadGatewayReloadPolicy()).resolves.toEqual({
mode: 'restart',
debounceMs: 60_000,
});
});
});

View File

@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest';
import { GatewayRestartGovernor } from '@electron/gateway/restart-governor';
describe('GatewayRestartGovernor', () => {
it('suppresses restart during exponential cooldown window', () => {
const governor = new GatewayRestartGovernor({
baseCooldownMs: 1000,
maxCooldownMs: 8000,
maxRestartsPerWindow: 10,
windowMs: 60000,
stableResetMs: 60000,
circuitOpenMs: 60000,
});
expect(governor.decide(1000).allow).toBe(true);
governor.recordExecuted(1000);
const blocked = governor.decide(1500);
expect(blocked.allow).toBe(false);
expect(blocked.allow ? '' : blocked.reason).toBe('cooldown_active');
expect(blocked.allow ? 0 : blocked.retryAfterMs).toBeGreaterThan(0);
expect(governor.decide(3000).allow).toBe(true);
});
it('opens circuit after restart budget is exceeded', () => {
const governor = new GatewayRestartGovernor({
maxRestartsPerWindow: 2,
windowMs: 60000,
baseCooldownMs: 0,
maxCooldownMs: 0,
stableResetMs: 120000,
circuitOpenMs: 30000,
});
expect(governor.decide(1000).allow).toBe(true);
governor.recordExecuted(1000);
expect(governor.decide(2000).allow).toBe(true);
governor.recordExecuted(2000);
const budgetBlocked = governor.decide(3000);
expect(budgetBlocked.allow).toBe(false);
expect(budgetBlocked.allow ? '' : budgetBlocked.reason).toBe('budget_exceeded');
const circuitBlocked = governor.decide(4000);
expect(circuitBlocked.allow).toBe(false);
expect(circuitBlocked.allow ? '' : circuitBlocked.reason).toBe('circuit_open');
expect(governor.decide(62001).allow).toBe(true);
});
it('resets consecutive backoff after stable running period', () => {
const governor = new GatewayRestartGovernor({
baseCooldownMs: 1000,
maxCooldownMs: 8000,
maxRestartsPerWindow: 10,
windowMs: 600000,
stableResetMs: 5000,
circuitOpenMs: 60000,
});
governor.recordExecuted(0);
governor.recordExecuted(1000);
const blockedBeforeStable = governor.decide(2500);
expect(blockedBeforeStable.allow).toBe(false);
expect(blockedBeforeStable.allow ? '' : blockedBeforeStable.reason).toBe('cooldown_active');
governor.onRunning(3000);
const allowedAfterStable = governor.decide(9000);
expect(allowedAfterStable.allow).toBe(true);
});
it('resets time-based state when clock moves backwards', () => {
const governor = new GatewayRestartGovernor({
maxRestartsPerWindow: 2,
windowMs: 60000,
baseCooldownMs: 1000,
maxCooldownMs: 8000,
stableResetMs: 60000,
circuitOpenMs: 30000,
});
governor.recordExecuted(10_000);
governor.recordExecuted(11_000);
const blocked = governor.decide(11_500);
expect(blocked.allow).toBe(false);
// Simulate clock rewind and verify stale guard state does not lock out restarts.
const afterRewind = governor.decide(9_000);
expect(afterRewind.allow).toBe(true);
});
it('wraps counters safely at MAX_SAFE_INTEGER', () => {
const governor = new GatewayRestartGovernor();
(governor as unknown as { executedTotal: number; suppressedTotal: number }).executedTotal = Number.MAX_SAFE_INTEGER;
(governor as unknown as { executedTotal: number; suppressedTotal: number }).suppressedTotal = Number.MAX_SAFE_INTEGER;
governor.recordExecuted(1000);
governor.decide(1000);
expect(governor.getCounters()).toEqual({
executedTotal: 0,
suppressedTotal: 0,
});
});
});

View File

@@ -155,4 +155,23 @@ describe('openclaw doctor output handling', () => {
expect(result.stdout).toBe('line-1\nline-2\n');
expect(result.stderr).toBe('warn-1\nwarn-2\n');
});
it('runs plain doctor command without --json', async () => {
const child = new MockUtilityChild();
mockFork.mockReturnValue(child);
const { runOpenClawDoctor } = await import('@electron/utils/openclaw-doctor');
const resultPromise = runOpenClawDoctor();
await vi.waitFor(() => {
expect(mockFork).toHaveBeenCalledTimes(1);
});
child.stdout.emit('data', Buffer.from('doctor ok\n'));
child.emit('exit', 0);
const result = await resultPromise;
expect(result.success).toBe(true);
expect(result.command).toBe('openclaw doctor');
expect(mockFork.mock.calls[0][1]).toEqual(['doctor']);
});
});