diff --git a/app/src/components/settings/panels/TeamInvitesPanel.tsx b/app/src/components/settings/panels/TeamInvitesPanel.tsx
index e3df24b679..f0016ae5e6 100644
--- a/app/src/components/settings/panels/TeamInvitesPanel.tsx
+++ b/app/src/components/settings/panels/TeamInvitesPanel.tsx
@@ -1,12 +1,16 @@
+import debug from 'debug';
import { useEffect, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
+import { sanitizeError } from '../../../utils/sanitize';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
+const log = debug('core-rpc:error');
+
const TeamInvitesPanel = () => {
const { t } = useT();
const { teamId } = useParams<{ teamId: string }>();
@@ -34,7 +38,15 @@ const TeamInvitesPanel = () => {
useEffect(() => {
if (!currentTeamId) return;
setIsLoadingInvites(true);
- void refreshTeamInvites(currentTeamId).finally(() => setIsLoadingInvites(false));
+ // `.finally()` alone left this as `void promise(...)`, so any rejection
+ // (cold core boot, backend 504, local AbortController timeout) became an
+ // unhandled rejection → OPENHUMAN-REACT-12. Swallow into a logged
+ // breadcrumb; the user can retry by navigating away and back.
+ refreshTeamInvites(currentTeamId)
+ .catch(err => {
+ log('refreshTeamInvites failed in TeamInvitesPanel: %O', sanitizeError(err));
+ })
+ .finally(() => setIsLoadingInvites(false));
}, [currentTeamId, refreshTeamInvites]);
const handleGenerate = async () => {
diff --git a/app/src/components/settings/panels/TeamMembersPanel.tsx b/app/src/components/settings/panels/TeamMembersPanel.tsx
index a7acaad0a2..3c15629c31 100644
--- a/app/src/components/settings/panels/TeamMembersPanel.tsx
+++ b/app/src/components/settings/panels/TeamMembersPanel.tsx
@@ -1,3 +1,4 @@
+import debug from 'debug';
import { useEffect, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
@@ -5,9 +6,12 @@ import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import type { TeamMember, TeamRole } from '../../../types/team';
+import { sanitizeError } from '../../../utils/sanitize';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
+const log = debug('core-rpc:error');
+
const ROLES: TeamRole[] = ['ADMIN', 'BILLING_MANAGER', 'MEMBER'];
const TeamMembersPanel = () => {
@@ -41,7 +45,15 @@ const TeamMembersPanel = () => {
useEffect(() => {
if (!currentTeamId) return;
setIsLoadingMembers(true);
- void refreshTeamMembers(currentTeamId).finally(() => setIsLoadingMembers(false));
+ // `.finally()` alone left this as `void promise(...)`, so any rejection
+ // (cold core boot, backend 504, local AbortController timeout) became an
+ // unhandled rejection → OPENHUMAN-REACT-10. Swallow into a logged
+ // breadcrumb; the user can retry by navigating away and back.
+ refreshTeamMembers(currentTeamId)
+ .catch(err => {
+ log('refreshTeamMembers failed in TeamMembersPanel: %O', sanitizeError(err));
+ })
+ .finally(() => setIsLoadingMembers(false));
}, [currentTeamId, refreshTeamMembers]);
const handleChangeRole = (member: TeamMember, newRole: TeamRole) => {
diff --git a/app/src/components/settings/panels/TeamPanel.tsx b/app/src/components/settings/panels/TeamPanel.tsx
index 66ca9be181..746d46d8bd 100644
--- a/app/src/components/settings/panels/TeamPanel.tsx
+++ b/app/src/components/settings/panels/TeamPanel.tsx
@@ -1,12 +1,17 @@
+import debug from 'debug';
import { useCallback, useEffect, useState } from 'react';
import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
+import { CoreRpcError } from '../../../services/coreRpcClient';
import type { TeamWithRole } from '../../../types/team';
+import { sanitizeError } from '../../../utils/sanitize';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
+const log = debug('core-rpc:error');
+
const TeamPanel = () => {
const { t } = useT();
const { navigateBack, navigateToTeamManagement, breadcrumbs } = useSettingsNavigation();
@@ -30,13 +35,26 @@ const TeamPanel = () => {
setIsLoading(true);
try {
await refreshTeams();
+ } catch (err) {
+ // Bootstrap-time `team_list_teams` failures (cold core boot, backend
+ // 504, local AbortController hit `CORE_RPC_TIMEOUT_MS`) used to leak
+ // as unhandled promise rejections via the `void` in the useEffect
+ // below, polluting Sentry as OPENHUMAN-REACT-15/11. The next visible
+ // user action retries, so swallow silently for transient kinds.
+ const kind = err instanceof CoreRpcError ? err.kind : 'unknown';
+ log('refreshTeams failed in TeamPanel (kind=%s): %O', kind, sanitizeError(err));
} finally {
setIsLoading(false);
}
}, [refreshTeams]);
useEffect(() => {
- void refreshTeamsWithLoading();
+ // `refreshTeamsWithLoading` already absorbs rejections internally, but
+ // keep the `.catch()` as a belt-and-suspenders guard so a future refactor
+ // that re-throws cannot regress the unhandled-rejection family.
+ refreshTeamsWithLoading().catch(err => {
+ log('refreshTeamsWithLoading rethrew unexpectedly: %O', sanitizeError(err));
+ });
}, [refreshTeamsWithLoading]);
const handleCreateTeam = async () => {
diff --git a/app/src/components/settings/panels/__tests__/TeamInvitesPanel.test.tsx b/app/src/components/settings/panels/__tests__/TeamInvitesPanel.test.tsx
new file mode 100644
index 0000000000..d542a8b3e2
--- /dev/null
+++ b/app/src/components/settings/panels/__tests__/TeamInvitesPanel.test.tsx
@@ -0,0 +1,63 @@
+/**
+ * TeamInvitesPanel — unhandled-rejection guard test.
+ *
+ * Regression coverage for OPENHUMAN-REACT-12: bootstrap-time
+ * `team_list_invites` failures leaked through the
+ * `void refreshTeamInvites(...).finally(...)` pattern. The new explicit
+ * `.catch()` chained before `.finally()` must absorb the rejection
+ * silently.
+ */
+import { render, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+import { useCoreState } from '../../../../providers/CoreStateProvider';
+import { CoreRpcError } from '../../../../services/coreRpcClient';
+import TeamInvitesPanel from '../TeamInvitesPanel';
+
+vi.mock('../../../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() }));
+vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
+vi.mock('../../hooks/useSettingsNavigation', () => ({
+ useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
+}));
+vi.mock('../../components/SettingsHeader', () => ({ default: () => null }));
+vi.mock('react-router-dom', () => ({
+ useParams: () => ({ teamId: 'team-u1' }),
+ useLocation: () => ({ pathname: '/team/manage/team-u1' }),
+}));
+
+describe('TeamInvitesPanel — unhandled-rejection guard (#REACT-12)', () => {
+ let urEvents: PromiseRejectionEvent[];
+ const urHandler = (e: PromiseRejectionEvent) => {
+ urEvents.push(e);
+ };
+
+ beforeEach(() => {
+ urEvents = [];
+ window.addEventListener('unhandledrejection', urHandler);
+ });
+
+ afterEach(() => {
+ window.removeEventListener('unhandledrejection', urHandler);
+ vi.clearAllMocks();
+ });
+
+ test('swallows refreshTeamInvites CoreRpcError(timeout) without unhandledrejection', async () => {
+ const refreshTeamInvites = vi
+ .fn()
+ .mockRejectedValue(
+ new CoreRpcError('Core RPC openhuman.team_list_invites timed out after 30000ms', 'timeout')
+ );
+ vi.mocked(useCoreState).mockReturnValue({
+ snapshot: { currentUser: { _id: 'u1', activeTeamId: 'team-u1' } },
+ teams: [{ team: { _id: 'team-u1', name: 'T' }, role: 'ADMIN' }],
+ teamInvitesById: {},
+ refreshTeamInvites,
+ } as never);
+
+ render();
+ await waitFor(() => expect(refreshTeamInvites).toHaveBeenCalledWith('team-u1'));
+ await new Promise(r => setTimeout(r, 20));
+
+ expect(urEvents).toHaveLength(0);
+ });
+});
diff --git a/app/src/components/settings/panels/__tests__/TeamMembersPanel.test.tsx b/app/src/components/settings/panels/__tests__/TeamMembersPanel.test.tsx
new file mode 100644
index 0000000000..40217311f6
--- /dev/null
+++ b/app/src/components/settings/panels/__tests__/TeamMembersPanel.test.tsx
@@ -0,0 +1,63 @@
+/**
+ * TeamMembersPanel — unhandled-rejection guard test.
+ *
+ * Regression coverage for OPENHUMAN-REACT-10: bootstrap-time
+ * `team_list_members` failures leaked through the
+ * `void refreshTeamMembers(...).finally(...)` pattern. The new explicit
+ * `.catch()` chained before `.finally()` must absorb the rejection
+ * silently.
+ */
+import { render, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+import { useCoreState } from '../../../../providers/CoreStateProvider';
+import { CoreRpcError } from '../../../../services/coreRpcClient';
+import TeamMembersPanel from '../TeamMembersPanel';
+
+vi.mock('../../../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() }));
+vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
+vi.mock('../../hooks/useSettingsNavigation', () => ({
+ useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
+}));
+vi.mock('../../components/SettingsHeader', () => ({ default: () => null }));
+vi.mock('react-router-dom', () => ({
+ useParams: () => ({ teamId: 'team-u1' }),
+ useLocation: () => ({ pathname: '/team/manage/team-u1' }),
+}));
+
+describe('TeamMembersPanel — unhandled-rejection guard (#REACT-10)', () => {
+ let urEvents: PromiseRejectionEvent[];
+ const urHandler = (e: PromiseRejectionEvent) => {
+ urEvents.push(e);
+ };
+
+ beforeEach(() => {
+ urEvents = [];
+ window.addEventListener('unhandledrejection', urHandler);
+ });
+
+ afterEach(() => {
+ window.removeEventListener('unhandledrejection', urHandler);
+ vi.clearAllMocks();
+ });
+
+ test('swallows refreshTeamMembers CoreRpcError(timeout) without unhandledrejection', async () => {
+ const refreshTeamMembers = vi
+ .fn()
+ .mockRejectedValue(
+ new CoreRpcError('Core RPC openhuman.team_list_members timed out after 30000ms', 'timeout')
+ );
+ vi.mocked(useCoreState).mockReturnValue({
+ snapshot: { currentUser: { _id: 'u1', activeTeamId: 'team-u1' } },
+ teams: [{ team: { _id: 'team-u1', name: 'T' }, role: 'ADMIN' }],
+ teamMembersById: {},
+ refreshTeamMembers,
+ } as never);
+
+ render();
+ await waitFor(() => expect(refreshTeamMembers).toHaveBeenCalledWith('team-u1'));
+ await new Promise(r => setTimeout(r, 20));
+
+ expect(urEvents).toHaveLength(0);
+ });
+});
diff --git a/app/src/components/settings/panels/__tests__/TeamPanel.test.tsx b/app/src/components/settings/panels/__tests__/TeamPanel.test.tsx
new file mode 100644
index 0000000000..c6ab803fab
--- /dev/null
+++ b/app/src/components/settings/panels/__tests__/TeamPanel.test.tsx
@@ -0,0 +1,88 @@
+/**
+ * TeamPanel — unhandled-rejection guard tests.
+ *
+ * Regression coverage for OPENHUMAN-REACT-15 / REACT-11: bootstrap-time
+ * `team_list_teams` failures (cold core boot, backend 504, local
+ * AbortController timeout) used to leak as `void promise(...)` unhandled
+ * rejections via the `useEffect` mount handler. The new `.catch()` in
+ * `refreshTeamsWithLoading` plus the defensive `.catch()` on the
+ * `useEffect` invocation must absorb the rejection silently.
+ */
+import { render, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+import { useCoreState } from '../../../../providers/CoreStateProvider';
+import { CoreRpcError } from '../../../../services/coreRpcClient';
+import TeamPanel from '../TeamPanel';
+
+vi.mock('../../../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() }));
+vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
+vi.mock('../../hooks/useSettingsNavigation', () => ({
+ useSettingsNavigation: () => ({
+ navigateBack: vi.fn(),
+ navigateToTeamManagement: vi.fn(),
+ breadcrumbs: [],
+ }),
+}));
+vi.mock('../../components/SettingsHeader', () => ({ default: () => null }));
+
+describe('TeamPanel — unhandled-rejection guard (#REACT-15)', () => {
+ let urEvents: PromiseRejectionEvent[];
+ const urHandler = (e: PromiseRejectionEvent) => {
+ urEvents.push(e);
+ };
+
+ beforeEach(() => {
+ urEvents = [];
+ window.addEventListener('unhandledrejection', urHandler);
+ });
+
+ afterEach(() => {
+ window.removeEventListener('unhandledrejection', urHandler);
+ vi.clearAllMocks();
+ });
+
+ test('swallows refreshTeams CoreRpcError(timeout) without unhandledrejection', async () => {
+ const refreshTeams = vi
+ .fn()
+ .mockRejectedValue(
+ new CoreRpcError('Core RPC openhuman.team_list_teams timed out after 30000ms', 'timeout')
+ );
+ vi.mocked(useCoreState).mockReturnValue({
+ snapshot: { currentUser: { _id: 'u1', activeTeamId: 'team-u1' } },
+ teams: [],
+ refresh: vi.fn(),
+ refreshTeams,
+ } as never);
+
+ render();
+
+ // Mount-effect must have fired refreshTeams and observed the rejection.
+ await waitFor(() => expect(refreshTeams).toHaveBeenCalled());
+ // Flush microtasks so the `.catch()` chain has a chance to settle.
+ await new Promise(r => setTimeout(r, 20));
+
+ expect(urEvents).toHaveLength(0);
+ });
+
+ test('swallows transport-kind refreshTeams failure without unhandledrejection', async () => {
+ // Backend 504 / connect-refused shape (REACT-13 / REACT-14 family) must
+ // also be absorbed — the `.catch()` is unconditional, not
+ // kind-gated.
+ const refreshTeams = vi
+ .fn()
+ .mockRejectedValue(new CoreRpcError('backend request GET /teams', 'transport'));
+ vi.mocked(useCoreState).mockReturnValue({
+ snapshot: { currentUser: { _id: 'u1', activeTeamId: 'team-u1' } },
+ teams: [],
+ refresh: vi.fn(),
+ refreshTeams,
+ } as never);
+
+ render();
+ await waitFor(() => expect(refreshTeams).toHaveBeenCalled());
+ await new Promise(r => setTimeout(r, 20));
+
+ expect(urEvents).toHaveLength(0);
+ });
+});
diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx
index ad0578b2e7..6aad15355a 100644
--- a/app/src/providers/CoreStateProvider.tsx
+++ b/app/src/providers/CoreStateProvider.tsx
@@ -559,7 +559,15 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
const updateLocalState = useCallback(
async (params: Parameters[0]) => {
await updateCoreLocalState(params);
- await refresh();
+ // The follow-up refresh is best-effort cache reconciliation, not part
+ // of the write contract — sibling helpers (setAnalyticsEnabled,
+ // setMeetAutoOrchestratorHandoff, …) already swallow here. An
+ // un-caught `app_state_snapshot` timeout used to bubble out of
+ // `setEncryptionKey` / `setOnboardingTasks` callers as an unhandled
+ // rejection → OPENHUMAN-REACT-Z/Y. The next poll tick will reconcile.
+ await refresh().catch(err => {
+ log('refresh failed after updateLocalState: %O', sanitizeError(err));
+ });
},
[refresh]
);
@@ -580,7 +588,13 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
// restartApp call here was redundant and skipped the persist purge,
// letting redux-persist rehydrate the prior user's slices on launch
// (#900). Restart now happens inside handleIdentityFlip after purge.
- await refresh();
+ // Swallow refresh failures here so a cold-boot `app_state_snapshot`
+ // timeout post-login doesn't surface as an unhandled rejection
+ // (OPENHUMAN-REACT-Z/Y) — the polling loop reconciles within
+ // `POLL_MS`.
+ await refresh().catch(err => {
+ log('refresh failed after session store: %O', sanitizeError(err));
+ });
await refreshTeams().catch(err => {
log('refreshTeams failed after session store: %O', sanitizeError(err));
});
diff --git a/app/src/providers/__tests__/CoreStateProvider.test.tsx b/app/src/providers/__tests__/CoreStateProvider.test.tsx
index 26da31e297..9476b48c2c 100644
--- a/app/src/providers/__tests__/CoreStateProvider.test.tsx
+++ b/app/src/providers/__tests__/CoreStateProvider.test.tsx
@@ -481,6 +481,79 @@ describe('CoreStateProvider — identity-change cache clearing', () => {
expect(screen.getByTestId('token').textContent).toBe('tok1');
});
+ it('setEncryptionKey (updateLocalState) swallows refresh errors after the local-state write lands (#REACT-Z #REACT-Y)', async () => {
+ // Regression for OPENHUMAN-REACT-Z/Y: a missing `.catch()` on the
+ // follow-up `refresh()` inside `updateLocalState` let an
+ // `app_state_snapshot` timeout bubble out as an unhandled rejection.
+ fetchSnapshot.mockResolvedValueOnce(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' }));
+ listTeams.mockResolvedValue([]);
+ vi.mocked(coreStateApi.updateCoreLocalState).mockReset();
+ vi.mocked(coreStateApi.updateCoreLocalState).mockResolvedValue(undefined as never);
+
+ let ctx: CoreStateContextValue | undefined;
+ render(
+
+ {
+ ctx = next;
+ }}
+ />
+
+ );
+
+ await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
+ fetchSnapshot.mockRejectedValueOnce(
+ new Error('Core RPC openhuman.app_state_snapshot timed out after 30000ms')
+ );
+
+ await act(async () => {
+ // setEncryptionKey is a thin sync wrapper around updateLocalState
+ // (provider line 694) — exercising it covers the new .catch() arm
+ // on line 579-580.
+ await expect(ctx!.setEncryptionKey('new-key')).resolves.toBeUndefined();
+ });
+
+ expect(vi.mocked(coreStateApi.updateCoreLocalState)).toHaveBeenCalledWith({
+ encryptionKey: 'new-key',
+ });
+ });
+
+ it('storeSessionToken swallows refresh errors after the session write lands (#REACT-Z #REACT-Y)', async () => {
+ // Regression for OPENHUMAN-REACT-Z/Y: a missing `.catch()` on the
+ // post-login `refresh()` inside `storeSessionToken` let an
+ // `app_state_snapshot` timeout bubble out as an unhandled rejection
+ // immediately after sign-in.
+ fetchSnapshot.mockResolvedValueOnce(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' }));
+ listTeams.mockResolvedValue([]);
+ vi.mocked(tauriCommands.storeSession).mockReset();
+ vi.mocked(tauriCommands.storeSession).mockResolvedValue(undefined as never);
+ vi.mocked(tauriCommands.syncMemoryClientToken).mockReset();
+ vi.mocked(tauriCommands.syncMemoryClientToken).mockResolvedValue(undefined as never);
+
+ let ctx: CoreStateContextValue | undefined;
+ render(
+
+ {
+ ctx = next;
+ }}
+ />
+
+ );
+
+ await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready'));
+ fetchSnapshot.mockRejectedValueOnce(
+ new Error('Core RPC openhuman.app_state_snapshot timed out after 30000ms')
+ );
+
+ await act(async () => {
+ const token = makeJwt({ sub: 'u1', exp: Math.floor(Date.now() / 1000) + 3600 });
+ await expect(ctx!.storeSessionToken(token, {})).resolves.toBeUndefined();
+ });
+
+ expect(vi.mocked(tauriCommands.storeSession)).toHaveBeenCalled();
+ });
+
it('setMeetAutoOrchestratorHandoff swallows refresh errors after the RPC succeeds (#1299)', async () => {
fetchSnapshot.mockResolvedValueOnce(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' }));
listTeams.mockResolvedValue([]);
diff --git a/app/src/services/__tests__/analytics.test.ts b/app/src/services/__tests__/analytics.test.ts
index c3ea9c4036..a568961f3f 100644
--- a/app/src/services/__tests__/analytics.test.ts
+++ b/app/src/services/__tests__/analytics.test.ts
@@ -79,6 +79,11 @@ vi.mock('../../utils/config', () => ({
SENTRY_DSN: 'https://abc@example.ingest.sentry.io/1',
SENTRY_RELEASE: 'openhuman@test+abc',
SENTRY_SMOKE_TEST: false,
+ // analytics.ts now imports CoreRpcError from coreRpcClient, whose
+ // dependency chain reads CORE_RPC_URL and CORE_RPC_TIMEOUT_MS. Provide
+ // stub values so the module graph loads under this mock.
+ CORE_RPC_URL: 'http://127.0.0.1:7788/rpc',
+ CORE_RPC_TIMEOUT_MS: 30000,
}));
describe('triggerSentryTestEvent', () => {
@@ -155,14 +160,20 @@ describe('triggerSentryTestEvent', () => {
describe('initSentry beforeSend manual-staging bypass', () => {
/** Capture the `beforeSend` callback that `initSentry` registers. */
async function captureBeforeSend(): Promise<
- (event: Record) => Record | null
+ (
+ event: Record,
+ hint?: { originalException?: unknown }
+ ) => Record | null
> {
hoisted.init.mockReset();
const { initSentry } = await import('../analytics');
initSentry();
expect(hoisted.init).toHaveBeenCalledTimes(1);
const opts = hoisted.init.mock.calls[0][0] as {
- beforeSend: (event: Record) => Record | null;
+ beforeSend: (
+ event: Record,
+ hint?: { originalException?: unknown }
+ ) => Record | null;
};
return opts.beforeSend.bind(opts);
}
@@ -223,6 +234,60 @@ describe('initSentry beforeSend manual-staging bypass', () => {
expect(result).not.toBeNull();
});
+ test('drops CoreRpcError with kind=timeout via the originalException hint', async () => {
+ // Regression for OPENHUMAN-REACT-15/11/10/12/Z/Y: a missed `.catch()` at
+ // any `await callCoreRpc(...)` chain in the team panels surfaced as an
+ // unhandled rejection captured by `auto.browser.global_handlers`. Even
+ // with .catch() landed, future call sites must not regress the family
+ // — this filter is the last line of defense.
+ hoisted.analyticsEnabled = true; // consent on so non-test events normally pass.
+ const beforeSend = await captureBeforeSend();
+ const { CoreRpcError } = await import('../coreRpcClient');
+ const timeoutErr = new CoreRpcError(
+ 'Core RPC openhuman.team_list_teams timed out after 30000ms',
+ 'timeout'
+ );
+
+ const result = beforeSend(
+ { message: 'CoreRpcError', tags: {}, contexts: {} },
+ { originalException: timeoutErr }
+ );
+ expect(result).toBeNull();
+ });
+
+ test('drops cross-realm CoreRpcError-shaped timeouts (name + kind match)', async () => {
+ // Test harnesses and dynamic imports can construct CoreRpcError in a
+ // separate module scope where `instanceof` fails. The filter must still
+ // demote them.
+ hoisted.analyticsEnabled = true;
+ const beforeSend = await captureBeforeSend();
+ const fakeErr = Object.assign(new Error('Core RPC X timed out after 30000ms'), {
+ name: 'CoreRpcError',
+ kind: 'timeout',
+ });
+
+ const result = beforeSend(
+ { message: 'CoreRpcError', tags: {}, contexts: {} },
+ { originalException: fakeErr }
+ );
+ expect(result).toBeNull();
+ });
+
+ test('lets non-timeout CoreRpcError shapes through (transport, auth_expired, …)', async () => {
+ hoisted.analyticsEnabled = true;
+ const beforeSend = await captureBeforeSend();
+ const { CoreRpcError } = await import('../coreRpcClient');
+ const transportErr = new CoreRpcError('error sending request', 'transport');
+
+ const result = beforeSend(
+ { message: 'CoreRpcError', tags: {}, contexts: {} },
+ { originalException: transportErr }
+ );
+ // Transport errors are still worth seeing — only the local 30s
+ // AbortController shape gets demoted at the source.
+ expect(result).not.toBeNull();
+ });
+
test('forwards release tag and registers httpContextIntegration (#1403)', async () => {
// Regression for #1403: production events arrived in Sentry with no
// `release` tag and no `os` context. The release must reach Sentry.init
diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts
index 33faf8aee2..e291ff0bdb 100644
--- a/app/src/services/__tests__/coreRpcClient.test.ts
+++ b/app/src/services/__tests__/coreRpcClient.test.ts
@@ -327,7 +327,14 @@ describe('coreRpcClient', () => {
await vi.advanceTimersByTimeAsync(CORE_RPC_TIMEOUT_MS + 1);
- await expect(pending).rejects.toThrow(
+ const err = await pending.catch(e => e);
+ // The timeout path must throw a CoreRpcError pre-classified as
+ // `timeout` so the outer catch does not re-wrap a bare `Error` and so
+ // Sentry / call-site `.catch()` can branch on `err.kind`. Regression
+ // guard for OPENHUMAN-REACT-Z/Y (the bare-Error shape pre-fix).
+ expect(err).toBeInstanceOf(CoreRpcError);
+ expect((err as CoreRpcError).kind).toBe('timeout');
+ expect((err as Error).message).toBe(
`Core RPC openhuman.threads_list timed out after ${CORE_RPC_TIMEOUT_MS}ms`
);
} finally {
@@ -519,6 +526,21 @@ describe('classifyRpcError', () => {
['client error (Connect) inner: dns', undefined, 'transport'],
['operation timed out after 30s', undefined, 'transport'],
['ECONNREFUSED 127.0.0.1:7788', undefined, 'transport'],
+ // OPENHUMAN-REACT-15/11/10/12 verbatim from Sentry — local AbortController
+ // timeout, NOT backend transport. Must classify as `timeout`.
+ ['Core RPC openhuman.team_list_teams timed out after 30000ms', undefined, 'timeout'],
+ ['Core RPC openhuman.team_list_members timed out after 30000ms', undefined, 'timeout'],
+ ['Core RPC openhuman.team_list_invites timed out after 30000ms', undefined, 'timeout'],
+ // OPENHUMAN-REACT-Z/Y verbatim (bare-Error shape pre-fix; now CoreRpcError
+ // with same message): still kind=timeout under the new classifier.
+ ['Core RPC openhuman.app_state_snapshot timed out after 30000ms', undefined, 'timeout'],
+ // OPENHUMAN-REACT-13 verbatim — backend-side connect timeout. Body never
+ // hits the `timed out after \d+ms` matcher and stays `transport`.
+ [
+ 'backend request GET /teams: error sending request for url (https://api.tinyhumans.ai/teams): client error (Connect): operation timed out',
+ undefined,
+ 'transport',
+ ],
['some random message', undefined, 'unknown'],
] as const)('%s => %s', (message, status, expected) => {
expect(classifyRpcError(message, status)).toBe(expected);
@@ -537,6 +559,15 @@ describe('classifyRpcError', () => {
classifyRpcError('thread thread-123 not found', undefined, { kind: 'ThreadNotFound' })
).toBe('thread_not_found');
});
+
+ test('local AbortController timeout precedence wins over generic transport regex', () => {
+ // The `timed out` substring also matches the broader transport arm; the
+ // `timed out after \d+ms` arm MUST run first so callers can distinguish
+ // a local 30s ceiling from a backend `client error (Connect)` timeout.
+ expect(classifyRpcError('Core RPC openhuman.team_list_teams timed out after 30000ms')).toBe(
+ 'timeout'
+ );
+ });
});
describe('coreRpcClient — typed errors + auth-expired event', () => {
diff --git a/app/src/services/analytics.ts b/app/src/services/analytics.ts
index 9277ffe0c9..5cd0966278 100644
--- a/app/src/services/analytics.ts
+++ b/app/src/services/analytics.ts
@@ -30,6 +30,7 @@ import {
SENTRY_RELEASE,
SENTRY_SMOKE_TEST,
} from '../utils/config';
+import { CoreRpcError } from './coreRpcClient';
// ---------------------------------------------------------------------------
// GA4 — module-level state
@@ -70,6 +71,20 @@ export function isAnalyticsEnabled(): boolean {
return getCoreStateSnapshot().snapshot.analyticsEnabled;
}
+/**
+ * Cross-realm-safe check for a `CoreRpcError` with `kind === 'timeout'`.
+ * `instanceof` can fail across module scopes (test harness, dynamic import,
+ * Vitest module isolation), so also accept a duck-typed match on `name`
+ * and `kind`. Used by the Sentry `beforeSend` filter to drop the
+ * OPENHUMAN-REACT-15/11/10/12/Z/Y family at the source.
+ */
+function isCoreRpcTimeoutError(err: unknown): boolean {
+ if (err instanceof CoreRpcError) return err.kind === 'timeout';
+ if (typeof err !== 'object' || err === null) return false;
+ const candidate = err as { name?: unknown; kind?: unknown };
+ return candidate.name === 'CoreRpcError' && candidate.kind === 'timeout';
+}
+
export function initSentry(): void {
if (!SENTRY_DSN) return;
@@ -105,7 +120,19 @@ export function initSentry(): void {
],
sendDefaultPii: false,
- beforeSend(event) {
+ beforeSend(event, hint) {
+ // Drop noisy local-AbortController RPC timeouts at the source so a
+ // missed `.catch()` at a future call site cannot regress the
+ // OPENHUMAN-REACT-15/11/10/12/Z/Y family. Sister to the Rust-side
+ // `is_session_expired_event` filter / loopback classifier in PR #2063.
+ // Cross-realm-safe: also accept a non-instanceof match on the
+ // class name + kind (test harness can construct CoreRpcError in a
+ // different module scope).
+ const original = hint?.originalException as unknown;
+ if (isCoreRpcTimeoutError(original)) {
+ return null;
+ }
+
// Always allow the smoke-test event through so pipeline validation works
// even when the user hasn't opted into analytics yet on first boot.
const isSmokeTest = event.message === 'react-sentry-smoke-test';
diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts
index 5870fc9ba0..911887ad5a 100644
--- a/app/src/services/coreRpcClient.ts
+++ b/app/src/services/coreRpcClient.ts
@@ -51,6 +51,7 @@ let resolvingCoreRpcToken: Promise | null = null;
export type CoreRpcErrorKind =
| 'auth_expired'
| 'transport'
+ | 'timeout'
| 'rate_limited'
| 'budget_exceeded'
| 'thread_not_found'
@@ -94,6 +95,10 @@ export function classifyRpcError(
if (/no backend session token/i.test(message)) return 'auth_expired';
if (/429.*rate.?limit/i.test(message)) return 'rate_limited';
if (/Budget exceeded|Insufficient budget/i.test(message)) return 'budget_exceeded';
+ // Local AbortController hit `CORE_RPC_TIMEOUT_MS` — distinct from backend
+ // `client error (Connect): operation timed out`. Must run BEFORE the
+ // `transport` arm so the more specific kind wins.
+ if (/timed out after \d+ms/i.test(message)) return 'timeout';
if (/error sending request|client error \(Connect\)|timed out|ECONNREFUSED/i.test(message)) {
return 'transport';
}
@@ -378,7 +383,13 @@ export async function callCoreRpc({
});
} catch (fetchErr) {
if (controller.signal.aborted) {
- throw new Error(`Core RPC ${payload.method} timed out after ${CORE_RPC_TIMEOUT_MS}ms`);
+ // Throw a fully-classified `CoreRpcError` here so the outer catch
+ // doesn't re-wrap a bare `Error` and so callers can branch on
+ // `err.kind === 'timeout'` (Sentry filter, soft toast skip).
+ throw new CoreRpcError(
+ `Core RPC ${payload.method} timed out after ${CORE_RPC_TIMEOUT_MS}ms`,
+ 'timeout'
+ );
}
throw fetchErr;
} finally {