fix(recovery): prompt users to download latest build after crash loops (#778)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Mega Mind
2026-04-22 15:07:48 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 1e3743f28e
commit 44fda19734
5 changed files with 177 additions and 67 deletions
+14 -3
View File
@@ -1,3 +1,6 @@
import { LATEST_APP_DOWNLOAD_URL } from '../utils/config';
import { openUrl } from '../utils/openUrl';
/**
* ErrorFallbackScreen
*
@@ -55,6 +58,9 @@ export default function ErrorFallbackScreen({
<p className="text-sm text-stone-400 text-center mb-6">
The application encountered an unexpected error and could not recover.
</p>
<p className="text-xs text-stone-500 text-center mb-6">
If this keeps happening after restart, install the latest version.
</p>
{/* Error details */}
<div className="bg-stone-800/50 border border-stone-700/50 rounded-xl p-4 mb-6">
@@ -73,10 +79,10 @@ export default function ErrorFallbackScreen({
</div>
{/* Actions */}
<div className="flex gap-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<button
onClick={onReset}
className="flex-1 bg-stone-700 hover:bg-stone-600 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors">
className="bg-stone-700 hover:bg-stone-600 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors">
Try to Recover
</button>
<button
@@ -84,9 +90,14 @@ export default function ErrorFallbackScreen({
window.location.hash = '#/home';
window.location.reload();
}}
className="flex-1 bg-coral-500 hover:bg-coral-600 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors">
className="bg-coral-500 hover:bg-coral-600 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors">
Reload App
</button>
<button
onClick={() => openUrl(LATEST_APP_DOWNLOAD_URL)}
className="bg-stone-800 hover:bg-stone-700 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors border border-stone-600">
Download Latest
</button>
</div>
</div>
</div>
@@ -1,9 +1,83 @@
import { useState } from 'react';
import { useDaemonHealth } from '../../hooks/useDaemonHealth';
import { useDaemonLifecycle } from '../../hooks/useDaemonLifecycle';
import { useCoreState } from '../../providers/CoreStateProvider';
import { LATEST_APP_DOWNLOAD_URL } from '../../utils/config';
import { openUrl } from '../../utils/openUrl';
interface ServiceBlockingGateProps {
children: React.ReactNode;
}
const ServiceBlockingGate = ({ children }: ServiceBlockingGateProps) => {
return <>{children}</>;
const { snapshot } = useCoreState();
const daemonHealth = useDaemonHealth();
const daemonLifecycle = useDaemonLifecycle();
const [isRestarting, setIsRestarting] = useState(false);
const [restartError, setRestartError] = useState<string | null>(null);
const hasSession = Boolean(snapshot.sessionToken);
const shouldShowRecoveryPrompt =
hasSession && daemonLifecycle.maxAttemptsReached && daemonHealth.status !== 'running';
const handleRetry = async () => {
setIsRestarting(true);
setRestartError(null);
try {
const restarted = await daemonHealth.restartDaemon();
if (restarted) {
daemonLifecycle.resetRetries();
return;
}
setRestartError('Retry failed. Download the latest app build and try again.');
} catch {
setRestartError('Retry failed. Download the latest app build and try again.');
} finally {
setIsRestarting(false);
}
};
const handleDownloadLatest = async () => {
await openUrl(LATEST_APP_DOWNLOAD_URL);
};
if (!shouldShowRecoveryPrompt) {
return <>{children}</>;
}
return (
<>
{children}
<div className="fixed inset-0 z-[10000] bg-stone-950/80 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-xl rounded-2xl border border-coral-500/30 bg-stone-900 p-6 shadow-2xl">
<h2 className="text-xl font-semibold text-white">OpenHuman core is unavailable</h2>
<p className="mt-2 text-sm text-stone-300">
We could not recover the local core service after multiple attempts. This can happen
after a critical crash or binary mismatch.
</p>
<p className="mt-2 text-sm text-stone-400">
Download the latest app build to restore a healthy core runtime.
</p>
{restartError && <p className="mt-3 text-sm text-coral-300">{restartError}</p>}
<div className="mt-5 flex gap-3">
<button
type="button"
onClick={handleRetry}
disabled={isRestarting}
className="rounded-lg border border-stone-600 px-4 py-2 text-sm text-stone-100 hover:bg-stone-800 disabled:opacity-60">
{isRestarting ? 'Retrying...' : 'Retry Core'}
</button>
<button
type="button"
onClick={handleDownloadLatest}
className="rounded-lg bg-coral-500 px-4 py-2 text-sm font-medium text-white hover:bg-coral-600">
Download Latest Version
</button>
</div>
</div>
</div>
</>
);
};
export default ServiceBlockingGate;
@@ -1,33 +1,38 @@
import { render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, type Mock } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as tauriCommands from '../../../utils/tauriCommands';
import ServiceBlockingGate from '../ServiceBlockingGate';
const mockOpenUrl = vi.fn();
const mockUseCoreState = vi.fn();
const mockUseDaemonHealth = vi.fn();
const mockUseDaemonLifecycle = vi.fn();
vi.mock('../../../utils/openUrl', () => ({
openUrl: (...args: unknown[]) => mockOpenUrl(...args),
}));
vi.mock('../../../utils/config', () => ({
LATEST_APP_DOWNLOAD_URL: 'https://github.com/tinyhumansai/openhuman/releases/latest',
}));
vi.mock('../../../providers/CoreStateProvider', () => ({ useCoreState: () => mockUseCoreState() }));
vi.mock('../../../hooks/useDaemonHealth', () => ({ useDaemonHealth: () => mockUseDaemonHealth() }));
vi.mock('../../../hooks/useDaemonLifecycle', () => ({
useDaemonLifecycle: () => mockUseDaemonLifecycle(),
}));
describe('ServiceBlockingGate', () => {
const mockIsTauri = tauriCommands.isTauri as Mock;
const mockServiceStatus = tauriCommands.openhumanServiceStatus as Mock;
const mockAgentStatus = tauriCommands.openhumanAgentServerStatus as Mock;
const mockInstall = tauriCommands.openhumanServiceInstall as Mock;
const mockStart = tauriCommands.openhumanServiceStart as Mock;
it('renders children directly outside Tauri', async () => {
mockIsTauri.mockReturnValue(false);
render(
<ServiceBlockingGate>
<div>App Content</div>
</ServiceBlockingGate>
);
await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument());
beforeEach(() => {
mockOpenUrl.mockReset();
mockUseCoreState.mockReturnValue({ snapshot: { sessionToken: 'token' } });
mockUseDaemonHealth.mockReturnValue({ status: 'running', restartDaemon: vi.fn() });
mockUseDaemonLifecycle.mockReturnValue({ maxAttemptsReached: false, resetRetries: vi.fn() });
});
it('renders children even when service is not installed', async () => {
mockIsTauri.mockReturnValue(true);
mockServiceStatus.mockResolvedValue({ result: { state: 'NotInstalled' }, logs: [] });
mockAgentStatus.mockResolvedValue({ result: { running: false }, logs: [] });
it('renders children and does not show recovery prompt when daemon is healthy', async () => {
render(
<ServiceBlockingGate>
<div>App Content</div>
@@ -35,15 +40,12 @@ describe('ServiceBlockingGate', () => {
);
await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument());
expect(screen.queryByText('OpenHuman Service Required')).not.toBeInTheDocument();
expect(screen.queryByText('OpenHuman core is unavailable')).not.toBeInTheDocument();
});
it('does not expose forced install actions from the app shell', async () => {
mockIsTauri.mockReturnValue(true);
mockServiceStatus.mockResolvedValue({ result: { state: 'NotInstalled' }, logs: [] });
mockAgentStatus.mockResolvedValue({ result: { running: false }, logs: [] });
mockInstall.mockResolvedValue({ result: { state: 'Stopped' }, logs: [] });
mockStart.mockResolvedValue({ result: { state: 'Running' }, logs: [] });
it('shows recovery prompt when daemon retries are exhausted', async () => {
mockUseDaemonHealth.mockReturnValue({ status: 'error', restartDaemon: vi.fn() });
mockUseDaemonLifecycle.mockReturnValue({ maxAttemptsReached: true, resetRetries: vi.fn() });
render(
<ServiceBlockingGate>
@@ -51,16 +53,13 @@ describe('ServiceBlockingGate', () => {
</ServiceBlockingGate>
);
await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument());
expect(screen.queryByRole('button', { name: 'Install Service' })).not.toBeInTheDocument();
expect(mockInstall).not.toHaveBeenCalled();
expect(mockStart).not.toHaveBeenCalled();
expect(screen.getByText('OpenHuman core is unavailable')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Download Latest Version' })).toBeInTheDocument();
});
it('renders children when service is running even if agent is not running', async () => {
mockIsTauri.mockReturnValue(true);
mockServiceStatus.mockResolvedValue({ result: { state: 'Running' }, logs: [] });
mockAgentStatus.mockResolvedValue({ result: { running: false }, logs: [] });
it('opens latest release page from recovery prompt', async () => {
mockUseDaemonHealth.mockReturnValue({ status: 'error', restartDaemon: vi.fn() });
mockUseDaemonLifecycle.mockReturnValue({ maxAttemptsReached: true, resetRetries: vi.fn() });
render(
<ServiceBlockingGate>
@@ -68,14 +67,23 @@ describe('ServiceBlockingGate', () => {
</ServiceBlockingGate>
);
await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument());
expect(screen.queryByText('OpenHuman Service Required')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Download Latest Version' }));
await waitFor(() => {
expect(mockOpenUrl).toHaveBeenCalledWith(
'https://github.com/tinyhumansai/openhuman/releases/latest'
);
});
});
it('renders children when service is running and agent probe fails', async () => {
mockIsTauri.mockReturnValue(true);
mockServiceStatus.mockResolvedValue({ result: { state: 'Running' }, logs: [] });
mockAgentStatus.mockRejectedValue(new Error('agent status unavailable'));
it('calls restartDaemon and resetRetries on successful retry', async () => {
const mockRestartDaemon = vi.fn().mockResolvedValue(true);
const mockResetRetries = vi.fn();
mockUseDaemonHealth.mockReturnValue({ status: 'error', restartDaemon: mockRestartDaemon });
mockUseDaemonLifecycle.mockReturnValue({
maxAttemptsReached: true,
resetRetries: mockResetRetries,
});
render(
<ServiceBlockingGate>
@@ -83,14 +91,18 @@ describe('ServiceBlockingGate', () => {
</ServiceBlockingGate>
);
await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument());
expect(screen.queryByText('OpenHuman Service Required')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry Core' }));
await waitFor(() => {
expect(mockRestartDaemon).toHaveBeenCalled();
expect(mockResetRetries).toHaveBeenCalled();
});
});
it('renders children when service is stopped but agent server is running (soft pass)', async () => {
mockIsTauri.mockReturnValue(true);
mockServiceStatus.mockResolvedValue({ result: { state: 'Stopped' }, logs: [] });
mockAgentStatus.mockResolvedValue({ result: { running: true }, logs: [] });
it('shows error message when restartDaemon returns false', async () => {
const mockRestartDaemon = vi.fn().mockResolvedValue(false);
mockUseDaemonHealth.mockReturnValue({ status: 'error', restartDaemon: mockRestartDaemon });
mockUseDaemonLifecycle.mockReturnValue({ maxAttemptsReached: true, resetRetries: vi.fn() });
render(
<ServiceBlockingGate>
@@ -98,14 +110,17 @@ describe('ServiceBlockingGate', () => {
</ServiceBlockingGate>
);
await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument());
expect(screen.queryByText('OpenHuman Service Required')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry Core' }));
await waitFor(() => {
expect(screen.getByText(/Retry failed/)).toBeInTheDocument();
});
});
it('renders children when service probe fails but agent server is running (soft pass)', async () => {
mockIsTauri.mockReturnValue(true);
mockServiceStatus.mockRejectedValue(new Error('service status unavailable'));
mockAgentStatus.mockResolvedValue({ result: { running: true }, logs: [] });
it('shows error message when restartDaemon throws', async () => {
const mockRestartDaemon = vi.fn().mockRejectedValue(new Error('daemon error'));
mockUseDaemonHealth.mockReturnValue({ status: 'error', restartDaemon: mockRestartDaemon });
mockUseDaemonLifecycle.mockReturnValue({ maxAttemptsReached: true, resetRetries: vi.fn() });
render(
<ServiceBlockingGate>
@@ -113,7 +128,10 @@ describe('ServiceBlockingGate', () => {
</ServiceBlockingGate>
);
await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument());
expect(screen.queryByText('OpenHuman Service Required')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry Core' }));
await waitFor(() => {
expect(screen.getByText(/Retry failed/)).toBeInTheDocument();
});
});
});
+1 -1
View File
@@ -96,7 +96,7 @@ export const SENTRY_RELEASE = BUILD_SHA
export const MINIMUM_SUPPORTED_APP_VERSION =
(import.meta.env.VITE_MINIMUM_SUPPORTED_APP_VERSION as string | undefined)?.trim() ?? '';
/** Recovery link opened when OAuth is blocked due to an outdated app build (build-time default; override via CI). */
/** URL for the latest app release download page. Used for OAuth version-gate recovery and crash-recovery prompts. Override via VITE_LATEST_APP_DOWNLOAD_URL for deployment-specific download pages. */
export const LATEST_APP_DOWNLOAD_URL =
(import.meta.env.VITE_LATEST_APP_DOWNLOAD_URL as string | undefined)?.trim() ||
'https://github.com/tinyhumansai/openhuman/releases/latest';
+10 -3
View File
@@ -255,7 +255,12 @@ PY
ASSET_URL="$(echo "${parsed}" | sed -n '3p')"
ASSET_SHA256="$(echo "${parsed}" | sed -n '4p')"
[ -n "${ASSET_URL}" ]
# Exit 0 on success, 2 when API responded but no compatible asset was found.
# Callers can distinguish "no asset" (2) from network/parse errors (1).
if [ -n "${ASSET_URL}" ]; then
return 0
fi
return 2
}
resolve_release_digest() {
@@ -292,8 +297,10 @@ if resolve_from_latest_json; then
log_ok "Resolved latest release via latest.json (${LATEST_VERSION})"
else
log_warn "latest.json lookup failed. Falling back to releases API."
if ! resolve_from_release_api; then
if [ "${DRY_RUN}" = true ]; then
resolve_from_release_api
resolve_rc=$?
if [ "${resolve_rc}" -ne 0 ]; then
if [ "${DRY_RUN}" = true ] && [ "${resolve_rc}" -eq 2 ]; then
if [ "${OS}" = "linux" ]; then
log_warn "No Linux release asset is currently published. Dry-run will skip install steps."
echo "DRY RUN: no compatible asset available for ${OS}/${ARCH}"