fix(startup): recover from core port 7788 conflict automatically (#2626)

This commit is contained in:
Mega Mind
2026-05-25 20:09:08 +05:30
committed by GitHub
parent d997394116
commit cdee8f73fb
27 changed files with 1209 additions and 45 deletions
+86
View File
@@ -143,6 +143,11 @@ describe('runBootCheck — local mode', () => {
vi.useRealTimers();
expect(result.kind).toBe('unreachable');
// start_core_process succeeded (invokeCmd resolves) so portConflict must NOT be set —
// the timeout alone is not evidence of a port conflict.
if (result.kind === 'unreachable') {
expect(result.portConflict).toBeFalsy();
}
});
});
@@ -214,6 +219,87 @@ describe('runBootCheck — unset mode', () => {
});
});
// ---------------------------------------------------------------------------
// Port conflict auto-recovery tests
// ---------------------------------------------------------------------------
describe('runBootCheck — port conflict auto-recovery', () => {
it('auto-recovery succeeds: start fails, recovery succeeds, second start succeeds', async () => {
const appVersion = (await import('../../utils/config')).APP_VERSION;
let startCallCount = 0;
const transport: BootCheckTransport = {
callRpc: rpcResponder({
'core.ping': {},
'openhuman.service_status': { installed: false, running: false },
'openhuman.update_version': { result: { version: appVersion } },
}),
invokeCmd: vi.fn(async (cmd: string) => {
if (cmd === 'start_core_process') {
startCallCount += 1;
if (startCallCount === 1) throw new Error('port in use');
return undefined;
}
return undefined;
}) as BootCheckTransport['invokeCmd'],
recoverPortConflict: vi
.fn()
.mockResolvedValue({ success: true, message: 'recovered', new_port: 7789 }),
};
const result = await runBootCheck({ kind: 'local' }, transport);
expect(result.kind).toBe('match');
expect(transport.recoverPortConflict).toHaveBeenCalled();
});
it('returns unreachable with portConflict=true when both start and recovery fail', async () => {
const transport: BootCheckTransport = {
callRpc: vi.fn(),
invokeCmd: vi.fn().mockRejectedValue(new Error('port in use')),
recoverPortConflict: vi
.fn()
.mockResolvedValue({ success: false, message: 'port still busy', new_port: undefined }),
};
const result = await runBootCheck({ kind: 'local' }, transport);
expect(result.kind).toBe('unreachable');
if (result.kind === 'unreachable') {
expect(result.portConflict).toBe(true);
}
});
it('clears RPC URL cache and retries waitForCore on timeout', async () => {
const appVersion = (await import('../../utils/config')).APP_VERSION;
let pingCallCount = 0;
const transport: BootCheckTransport = {
invokeCmd: vi.fn().mockResolvedValue(undefined),
callRpc: vi.fn(async (method: string) => {
if (method === 'core.ping') {
pingCallCount += 1;
// waitForCore(10_000) makes ~12 attempts with 200→1000ms exponential backoff.
// Fail exactly those 12 so the initial call times out; ping 13 succeeds so
// the cache-clear retry waitForCore(5_000) returns true on its first attempt.
if (pingCallCount <= 12) throw new Error('timeout');
return {};
}
if (method === 'openhuman.service_status') return { installed: false, running: false };
if (method === 'openhuman.update_version') return { result: { version: appVersion } };
throw new Error(`Unexpected RPC: ${method}`);
}) as BootCheckTransport['callRpc'],
};
vi.useFakeTimers();
const promise = runBootCheck({ kind: 'local' }, transport);
await vi.runAllTimersAsync();
const result = await promise;
vi.useRealTimers();
// Initial waitForCore timed out → cache cleared → second waitForCore succeeded.
expect(result.kind).toBe('match');
});
});
// ---------------------------------------------------------------------------
// Edge-case branches surfaced by the diff-coverage gate
// ---------------------------------------------------------------------------
+50 -8
View File
@@ -31,7 +31,7 @@ export type BootCheckResult =
| { kind: 'outdatedLocal' }
| { kind: 'outdatedCloud' }
| { kind: 'noVersionMethod' }
| { kind: 'unreachable'; reason: string };
| { kind: 'unreachable'; reason: string; portConflict?: boolean };
// ---------------------------------------------------------------------------
// Transport interface (injectable for tests)
@@ -42,6 +42,8 @@ export interface BootCheckTransport {
callRpc: <T>(method: string, params?: Record<string, unknown>) => Promise<T>;
/** Invoke a Tauri command. */
invokeCmd: <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;
/** Attempt to auto-recover from a port conflict. Optional — only wired in desktop builds. */
recoverPortConflict?: () => Promise<{ success: boolean; message: string; new_port?: number }>;
}
// The production transport lives in `app/src/services/bootCheckService.ts`
@@ -201,22 +203,62 @@ export async function runBootCheck(
if (mode.kind === 'local') {
log('[boot-check] local mode — starting core process');
let startFailed = false;
try {
await invokeCmd<void>('start_core_process', {});
log('[boot-check] start_core_process invoked successfully');
} catch (err) {
startFailed = true;
logError('[boot-check] start_core_process failed: %o', err);
return {
kind: 'unreachable',
reason: `Failed to start local core: ${err instanceof Error ? err.message : String(err)}`,
};
}
// If start failed, attempt port-conflict auto-recovery before giving up.
if (startFailed && transport.recoverPortConflict) {
log('[boot-check] start_core_process failed — attempting port-conflict auto-recovery');
try {
const recovery = await transport.recoverPortConflict();
log(
'[boot-check] port-conflict recovery result: success=%s message=%s',
recovery.success,
recovery.message
);
if (!recovery.success) {
logError('[boot-check] port-conflict recovery failed: %s', recovery.message);
return {
kind: 'unreachable',
reason: `Failed to start local core — port conflict recovery failed: ${recovery.message}`,
portConflict: true,
};
}
// Recovery succeeded — clear the URL cache so we pick up the new port.
clearCoreRpcUrlCache();
log('[boot-check] port-conflict recovery succeeded, RPC URL cache cleared');
} catch (recoveryErr) {
logError('[boot-check] port-conflict recovery threw: %o', recoveryErr);
return {
kind: 'unreachable',
reason: `Failed to start local core — recovery error: ${recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr)}`,
portConflict: true,
};
}
} else if (startFailed) {
return { kind: 'unreachable', reason: `Failed to start local core` };
}
// Wait for the embedded core to be reachable.
const reachable = await waitForCore(callRpc);
let reachable = await waitForCore(callRpc);
if (!reachable) {
logError('[boot-check] local core unreachable after retries');
return { kind: 'unreachable', reason: 'Local core did not respond in time' };
logError('[boot-check] local core unreachable after retries — trying cache clear + retry');
clearCoreRpcUrlCache();
reachable = await waitForCore(callRpc, 5_000);
}
if (!reachable) {
logError('[boot-check] local core unreachable after retries and cache clear');
return {
kind: 'unreachable',
reason: 'Local core did not respond in time',
portConflict: startFailed,
};
}
// Check for a legacy background daemon that should be removed.