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
@@ -14,7 +14,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { type BootCheckResult, runBootCheck } from '../../lib/bootCheck';
import { useT } from '../../lib/i18n/I18nContext';
import { bootCheckTransport } from '../../services/bootCheckService';
import { bootCheckTransport, recoverPortConflict } from '../../services/bootCheckService';
import {
clearCoreRpcTokenCache,
clearCoreRpcUrlCache,
@@ -407,16 +407,31 @@ function ResultScreen({
if (result.kind === 'match') return null;
if (result.kind === 'unreachable') {
const isPortConflict = result.portConflict === true;
return (
<Panel>
<h2 className="text-xl font-semibold text-stone-900 dark:text-neutral-100">
{t('bootCheck.cannotReach')}
{isPortConflict ? t('bootCheck.portConflictTitle') : t('bootCheck.cannotReach')}
</h2>
<p className="mt-2 text-sm text-stone-600 dark:text-neutral-300">
{result.reason || t('bootCheck.cannotReachDesc')}
{isPortConflict
? t('bootCheck.portConflictBody')
: result.reason || t('bootCheck.cannotReachDesc')}
</p>
{actionError && <p className="mt-3 text-xs text-red-600 font-medium">{actionError}</p>}
<div className="mt-5 flex gap-3">
<div className="mt-5 flex gap-3 flex-wrap">
{isPortConflict && (
<button
type="button"
onClick={onAction}
disabled={actionBusy}
data-testid="fix-automatically-btn"
className="rounded-lg bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600 disabled:opacity-60">
{actionBusy
? t('bootCheck.portConflictFixing')
: t('bootCheck.portConflictFixButton')}
</button>
)}
<button
type="button"
onClick={onRetry}
@@ -427,13 +442,15 @@ function ResultScreen({
<button
type="button"
onClick={onSwitchMode}
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60">
disabled={actionBusy}
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-4 py-2 text-sm text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60 disabled:opacity-60">
{t('bootCheck.switchMode')}
</button>
<button
type="button"
onClick={onQuit}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700">
disabled={actionBusy}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-60">
{t('bootCheck.quit')}
</button>
</div>
@@ -728,6 +745,18 @@ export default function BootCheckGate({ children }: BootCheckGateProps) {
log('[boot-check] gate — triggering cloud core update');
await transport.callRpc('openhuman.update_run', {});
log('[boot-check] gate — cloud core update triggered');
} else if (result.kind === 'unreachable' && result.portConflict) {
log('[boot-check-gate] port conflict — invoking recover_port_conflict');
const recovery = await recoverPortConflict();
log(
'[boot-check-gate] recovery result: success=%s message=%s',
recovery.success,
recovery.message
);
if (!recovery.success) {
setActionError(t('bootCheck.portConflictFixFailed'));
return;
}
}
// Re-run the full check after the action.
@@ -32,6 +32,15 @@ vi.mock('../../../lib/bootCheck', () => ({
runBootCheck: (...args: unknown[]) => mockRunBootCheck(...args),
}));
const mockRecoverPortConflict = vi.fn();
vi.mock('../../../services/bootCheckService', async importOriginal => {
const actual = await importOriginal<typeof import('../../../services/bootCheckService')>();
return {
...actual,
recoverPortConflict: (...args: unknown[]) => mockRecoverPortConflict(...args),
};
});
const mockTestCoreRpcConnection = vi.fn();
vi.mock('../../../services/coreRpcClient', () => ({
callCoreRpc: vi.fn(),
@@ -572,6 +581,125 @@ describe('BootCheckGate — pre-set mode (subsequent launches)', () => {
});
});
describe('BootCheckGate — port conflict recovery', () => {
beforeEach(() => {
mockRecoverPortConflict.mockReset();
mockRunBootCheck.mockReset();
});
it('shows "Fix Automatically" button when portConflict=true', async () => {
mockRunBootCheck.mockResolvedValue({
kind: 'unreachable',
reason: 'port conflict',
portConflict: true,
});
renderGate();
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(screen.getByTestId('fix-automatically-btn')).toBeInTheDocument();
});
expect(screen.getByTestId('fix-automatically-btn').textContent).toBe('Fix Automatically');
});
it('does not show "Fix Automatically" button when portConflict is not set', async () => {
mockRunBootCheck.mockResolvedValue({ kind: 'unreachable', reason: 'some other error' });
renderGate();
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(screen.getByText("Can't Reach the Runtime")).toBeInTheDocument();
});
expect(screen.queryByTestId('fix-automatically-btn')).not.toBeInTheDocument();
});
it('calls recoverPortConflict when "Fix Automatically" is clicked', async () => {
mockRunBootCheck
.mockResolvedValueOnce({ kind: 'unreachable', reason: 'port conflict', portConflict: true })
.mockResolvedValue({ kind: 'match' });
mockRecoverPortConflict.mockResolvedValue({ success: true, message: 'ok', new_port: 7789 });
renderGate();
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(screen.getByTestId('fix-automatically-btn')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('fix-automatically-btn'));
await waitFor(() => {
expect(mockRecoverPortConflict).toHaveBeenCalled();
});
});
it('re-runs boot check after successful recovery', async () => {
mockRunBootCheck
.mockResolvedValueOnce({ kind: 'unreachable', reason: 'port conflict', portConflict: true })
.mockResolvedValue({ kind: 'match' });
mockRecoverPortConflict.mockResolvedValue({ success: true, message: 'ok', new_port: 7789 });
renderGate();
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(screen.getByTestId('fix-automatically-btn')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('fix-automatically-btn'));
await waitFor(() => {
expect(screen.getByTestId('app-content')).toBeInTheDocument();
});
expect(mockRunBootCheck).toHaveBeenCalledTimes(2);
});
it('shows portConflictFixFailed message when recovery fails', async () => {
mockRunBootCheck.mockResolvedValue({
kind: 'unreachable',
reason: 'port conflict',
portConflict: true,
});
mockRecoverPortConflict.mockResolvedValue({
success: false,
message: 'still busy',
new_port: undefined,
});
renderGate();
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(screen.getByTestId('fix-automatically-btn')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('fix-automatically-btn'));
await waitFor(() => {
expect(
screen.getByText("Automatic fix didn't work. Please restart your computer and try again.")
).toBeInTheDocument();
});
});
it('"Pick a Different Runtime" still renders as secondary for port conflict', async () => {
mockRunBootCheck.mockResolvedValue({
kind: 'unreachable',
reason: 'port conflict',
portConflict: true,
});
renderGate();
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Pick a Different Runtime' })).toBeInTheDocument();
});
});
});
describe('BootCheckGate — picker (web build, !isTauri)', () => {
beforeEach(() => {
mockedIsTauri.mockReturnValue(false);