mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 06:32:24 +00:00
fix(startup): recover from core port 7788 conflict automatically (#2626)
This commit is contained in:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user