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