Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 109 additions & 1 deletion app/src/components/InitProgressScreen/HarnessInitOverlay.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { CoreRpcError } from '../../services/coreRpcClient';
import type { HarnessInitSnapshot } from '../../services/harnessInitService';
import { renderWithProviders } from '../../test/test-utils';
// Imported after the mock is registered.
Expand Down Expand Up @@ -43,6 +44,7 @@ beforeEach(() => {

afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});

describe('HarnessInitOverlay', () => {
Expand Down Expand Up @@ -116,4 +118,110 @@ describe('HarnessInitOverlay', () => {
renderWithProviders(<HarnessInitOverlay />);
expect(await screen.findByText('Run in background')).toBeInTheDocument();
});

// --- #5157: the poll loop must be bounded -------------------------------
//
// Before the fix, *any* status failure rescheduled the poll unconditionally
// every 2s for the life of the window. Against a core that never serves the
// method that was a permanent 30-calls-per-minute loop, and since the core
// records each miss it produced ~9k Sentry events/day from one client.

it('stops polling when the core does not expose harness_init_status (#5157)', async () => {
vi.useFakeTimers();
fetchHarnessInitStatus.mockRejectedValue(
new CoreRpcError('unknown method: openhuman.harness_init_status', 'method_not_found')
);

const { container } = renderWithProviders(<HarnessInitOverlay />);

// Let the immediate poll settle, then run well past many poll intervals.
await vi.advanceTimersByTimeAsync(0);
expect(fetchHarnessInitStatus).toHaveBeenCalledTimes(1);

await vi.advanceTimersByTimeAsync(120_000);

// Permanently absent ⇒ exactly one attempt, ever. No retry, no overlay.
expect(fetchHarnessInitStatus).toHaveBeenCalledTimes(1);
expect(container).toBeEmptyDOMElement();
});

it('gives up after a bounded number of consecutive transient failures (#5157)', async () => {
vi.useFakeTimers();
// A persistent non-method-not-found fault (core wedged, transport down).
fetchHarnessInitStatus.mockRejectedValue(new Error('error sending request for url'));

renderWithProviders(<HarnessInitOverlay />);

// 5 attempts, backing off 2s → 4s → 8s → 16s between them (30s total).
await vi.advanceTimersByTimeAsync(120_000);
expect(fetchHarnessInitStatus).toHaveBeenCalledTimes(5);

// And it stays stopped rather than resuming later.
await vi.advanceTimersByTimeAsync(600_000);
expect(fetchHarnessInitStatus).toHaveBeenCalledTimes(5);
});

// Review follow-up on #5157: the failure cap must not strand the *blocking*
// overlay. If the core has a transient outage that outlasts the budget while
// a `running` snapshot is on screen, giving up would pin the app behind stale
// progress for the rest of the session — the pre-#5157 loop recovered from
// exactly that. The cap still applies when nothing blocking is displayed
// (covered by the give-up test above); here the loop drops to a slow cadence
// instead of stopping.
it('keeps watching a running overlay through an outage longer than the failure budget', async () => {
vi.useFakeTimers();
fetchHarnessInitStatus
// A provisioning run is live — the overlay is now blocking the app.
.mockResolvedValueOnce(snapshot({ startedAt: 'stall-run' }))
// The core drops out for well past MAX_TRANSIENT_FAILURES attempts.
.mockRejectedValueOnce(new Error('error sending request for url'))
.mockRejectedValueOnce(new Error('error sending request for url'))
.mockRejectedValueOnce(new Error('error sending request for url'))
.mockRejectedValueOnce(new Error('error sending request for url'))
.mockRejectedValueOnce(new Error('error sending request for url'))
.mockRejectedValueOnce(new Error('error sending request for url'))
.mockRejectedValueOnce(new Error('error sending request for url'))
.mockRejectedValueOnce(new Error('error sending request for url'))
// ...and then comes back, having finished the run.
.mockResolvedValue(
snapshot({ overall: 'done', startedAt: 'stall-run', finishedAt: '2026-07-20T00:05:00Z' })
);

renderWithProviders(<HarnessInitOverlay />);

await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(screen.getByText('Run in background')).toBeInTheDocument();

await act(async () => {
await vi.advanceTimersByTimeAsync(300_000);
});

// It kept polling past the 5-attempt cap instead of freezing...
expect(fetchHarnessInitStatus.mock.calls.length).toBeGreaterThan(6);
// ...so the recovered core's terminal snapshot was observed and the
// blocking overlay cleared itself. (Asserted directly rather than through
// `waitFor`, which would wait on real timers while fake ones are installed.)
expect(screen.queryByText('Run in background')).not.toBeInTheDocument();
});

it('keeps polling after a transient failure while the core is still booting', async () => {
vi.useFakeTimers();
// The legitimate cold-start case the retry exists for: fail once, then the
// core comes up. The failure budget must reset on success, not leak.
fetchHarnessInitStatus
.mockRejectedValueOnce(new Error('error sending request for url'))
.mockResolvedValue(snapshot({ startedAt: 'cold-run' }));

renderWithProviders(<HarnessInitOverlay />);

await act(async () => {
await vi.advanceTimersByTimeAsync(2_000);
});

// Retried once and recovered — the overlay renders the live run.
expect(fetchHarnessInitStatus).toHaveBeenCalledTimes(2);
expect(screen.getByText('Run in background')).toBeInTheDocument();
});
});
108 changes: 103 additions & 5 deletions app/src/components/InitProgressScreen/HarnessInitOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import debugFactory from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';

import { isMethodNotFoundCoreRpcError } from '../../services/coreRpcClient';
import {
fetchHarnessInitStatus,
type HarnessInitSnapshot,
Expand All @@ -12,6 +13,49 @@ const log = debugFactory('harness-init');

const POLL_MS = 2000;

// A status poll can legitimately fail while the core is still coming up, so a
// failure is retried. But the retry must be *bounded*: before #5157 any failure
// rescheduled the poll unconditionally every 2s for the life of the window. A
// core that never serves this method (version skew / domain-gated build) turned
// that into a permanent 30-calls-per-minute loop, and because each miss is
// recorded core-side it produced ~9k Sentry events/day from a single client.
//
// Two guards now bound it:
// - a permanent failure (`method_not_found`) stops the loop immediately —
// retrying an absent method can never succeed;
// - any other failure gets a capped number of attempts with exponential
// backoff, so even an unforeseen persistent fault decays and stops.
const MAX_TRANSIENT_FAILURES = 5;
const MAX_BACKOFF_MS = 30_000;

// Exhausting the retries must not strand a *blocking* overlay on stale
// progress. While a `running` snapshot is on screen the UI is covering the app
// and waiting for a terminal snapshot, so a transient outage (brief core
// overload, network blip) that outlasts the cap has to stay watchable — giving
// up there would freeze the overlay on a half-finished run until the user hits
// "Run in background", and the pre-#5157 loop did recover from exactly that.
//
// So the cap still applies whenever nothing blocking is displayed (the #5157
// case: a core that never serves this method, with no UI on screen), and
// otherwise the loop drops to this much slower cadence instead of stopping.
// 2 calls/min is 15x below the runaway loop #5157 fixed, and only ever runs
// while a blocking overlay is actually up.
const STALLED_POLL_MS = MAX_BACKOFF_MS;

/** Backoff for the Nth consecutive transient failure (1-based), capped. */
function transientRetryDelayMs(consecutiveFailures: number): number {
return Math.min(POLL_MS * 2 ** (consecutiveFailures - 1), MAX_BACKOFF_MS);
}

/**
* Whether this snapshot puts the blocking overlay on screen. `running` is also
* the only non-terminal blocking state, so it is what the poll loop must keep
* watching for a terminal result.
*/
function isBlockingSnapshot(snapshot: HarnessInitSnapshot): boolean {
return snapshot.overall === 'running' || snapshot.overall === 'failed';
}

// Persist the "Run in background" dismissal for the *current* provisioning run
// so a remount or reload does not reopen the overlay (GH-5047). A run is keyed
// by its `startedAt` timestamp — a genuinely new provisioning run gets a fresh
Expand Down Expand Up @@ -102,19 +146,28 @@ export default function HarnessInitOverlay() {
const cancelledRef = useRef(false);
// Mirrors `dismissed` so the poll loop can stop without re-running the effect.
const dismissedRef = useRef(false);
// Mirrors "a blocking overlay is currently on screen, still waiting on a
// terminal snapshot", so the failure branch can tell a stranded user from a
// silent background loop without re-running the effect.
const awaitingTerminalRef = useRef(false);

useEffect(() => {
cancelledRef.current = false;
let timeoutId: number | null = null;

let consecutiveFailures = 0;

const poll = async () => {
let retryDelayMs = POLL_MS;
try {
const next = await fetchHarnessInitStatusCoalesced();
if (cancelledRef.current || dismissedRef.current) {
return;
}
consecutiveFailures = 0;
if (next) {
setSnapshot(next);
awaitingTerminalRef.current = next.overall === 'running';
// If this run was already dismissed to the background (possibly in a
// prior mount / before a reload), stay hidden and stop polling —
// don't let a remount reopen the overlay (GH-5047).
Expand All @@ -134,11 +187,53 @@ export default function HarnessInitOverlay() {
}
}
} catch (err) {
// Status can fail while the core is still coming up — keep polling.
log('status poll failed: %O', err);
if (cancelledRef.current || dismissedRef.current) {
return;
}
// The running core has no `harness_init_status` at all (version skew,
// domain-gated or slim build). Permanent — stop, and render nothing.
// There is no init run to report, so there is nothing to show (#5157).
if (isMethodNotFoundCoreRpcError(err)) {
log('status poll: core does not expose harness_init_status — stopping poll');
return;
}
consecutiveFailures += 1;
// Status can fail while the core is still coming up — keep polling, but
// only for a bounded number of attempts, backing off between each.
if (consecutiveFailures >= MAX_TRANSIENT_FAILURES) {
// Nothing blocking on screen: this is the #5157 runaway loop, stop.
if (!awaitingTerminalRef.current) {
log(
'status poll failed %d consecutive times — giving up: %O',
consecutiveFailures,
err
);
return;
}
// A `running` overlay is covering the app. Stopping here would pin it
// to stale progress for the rest of the session even after the core
// recovers, so keep watching at a much slower cadence instead.
retryDelayMs = STALLED_POLL_MS;
log(
'status poll failed %d consecutive times but a running overlay is on screen — ' +
'continuing at %dms: %O',
consecutiveFailures,
retryDelayMs,
err
);
} else {
retryDelayMs = transientRetryDelayMs(consecutiveFailures);
log(
'status poll failed (attempt %d/%d), retrying in %dms: %O',
consecutiveFailures,
MAX_TRANSIENT_FAILURES,
retryDelayMs,
err
);
}
}
if (!cancelledRef.current && !dismissedRef.current) {
timeoutId = window.setTimeout(() => void poll(), POLL_MS);
timeoutId = window.setTimeout(() => void poll(), retryDelayMs);
}
};

Expand Down Expand Up @@ -173,6 +268,10 @@ export default function HarnessInitOverlay() {
log('user dismissed overlay to background for run %s', runKey(snapshot));
writeDismissedRun(runKey(snapshot));
dismissedRef.current = true;
// Nothing is blocking any more, so a later failure must not keep the slow
// watch alive. (`dismissedRef` already stops the loop; this keeps the two
// flags from disagreeing.)
awaitingTerminalRef.current = false;
setDismissed(true);
}, [snapshot]);

Expand All @@ -188,8 +287,7 @@ export default function HarnessInitOverlay() {
// Block only while a run is actively in progress, or hold a failed run on
// screen until the user explicitly continues. `idle` (no run started yet)
// and `done` never block.
const shouldShow = snapshot.overall === 'running' || snapshot.overall === 'failed';
if (!shouldShow) {
if (!isBlockingSnapshot(snapshot)) {
return null;
}

Expand Down
14 changes: 14 additions & 0 deletions app/src/services/__tests__/coreRpcClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,10 @@ describe('classifyRpcError', () => {
['no backend session token; run auth_store_session first', undefined, 'auth_expired'],
['NO BACKEND SESSION TOKEN', undefined, 'auth_expired'],
['HTTP 429 rate-limit exceeded', undefined, 'rate_limited'],
// #5157 verbatim from Sentry (CORE-RUST-1PY) — the running core does not
// expose the method. Permanent, so pollers must be able to stop.
['unknown method: openhuman.harness_init_status', undefined, 'method_not_found'],
['unknown method: openhuman.memory_tree_create_namespace', undefined, 'method_not_found'],
['Budget exceeded for current period', undefined, 'budget_exceeded'],
['Insufficient budget for request', undefined, 'budget_exceeded'],
['error sending request for url', undefined, 'transport'],
Expand Down Expand Up @@ -752,6 +756,16 @@ describe('classifyRpcError', () => {
expect(classifyRpcError('anything', 429)).toBe('rate_limited');
});

test('unknown-method match is prefix-anchored, mirroring the Rust strip_prefix', () => {
// `dispatch::unknown_method_name` classifies with `strip_prefix`, so the
// frontend anchors identically — a nested/quoted occurrence is not the
// core telling us *this* call's method is absent.
expect(classifyRpcError('unknown method: openhuman.harness_init_status')).toBe(
'method_not_found'
);
expect(classifyRpcError('tool failed: unknown method: openhuman.foo_bar')).toBe('unknown');
});

test('structured ThreadNotFound data wins over message text', () => {
expect(
classifyRpcError('thread thread-123 not found', undefined, { kind: 'ThreadNotFound' })
Expand Down
24 changes: 24 additions & 0 deletions app/src/services/coreRpcClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,15 @@ type CoreRpcErrorKind =
| 'rate_limited'
| 'budget_exceeded'
| 'thread_not_found'
| 'method_not_found' // the running core does not expose this method — permanent
| 'unknown';

/**
* Prefix the core prepends to an unrecognised-method error. Mirrors
* `UNKNOWN_METHOD_PREFIX` in `src/core/dispatch.rs` — keep the two in sync.
*/
const UNKNOWN_METHOD_PREFIX = 'unknown method: ';

export class CoreRpcError extends Error {
readonly kind: CoreRpcErrorKind;
readonly httpStatus?: number;
Expand Down Expand Up @@ -147,6 +154,11 @@ export function classifyRpcError(
if (isThreadNotFoundRpcData(data)) return 'thread_not_found';
if (httpStatus === 401) return 'auth_expired';
if (httpStatus === 429) return 'rate_limited';
// The running core has no such method — a transport-boundary version skew
// (older core than the UI bundle, a domain-gated `DomainSet`, or a slim
// feature build), never a transient fault. Classified before the generic
// arms so polling callers can stop instead of retrying forever (#5157).
if (message.startsWith(UNKNOWN_METHOD_PREFIX)) return 'method_not_found';
// Confirmed OpenHuman session expiry — explicit markers from the backend/core.
if (/Session expired|SESSION_EXPIRED/i.test(message)) return 'auth_expired';
// Core-side "no backend session token" → the auth profile is gone but the
Expand Down Expand Up @@ -238,6 +250,18 @@ function threadIdFromRpcData(data: unknown): string | null {
return null;
}

/**
* Whether `error` is the core reporting that it does not expose the method.
*
* This is a **permanent** condition for the life of the connection: the method
* is absent from the running core's registry, so retrying can never succeed.
* Pollers must treat it as terminal and stop — an unbounded retry loop against
* an absent method produced ~9k Sentry events/day from a single client (#5157).
*/
export function isMethodNotFoundCoreRpcError(error: unknown): error is CoreRpcError {
return error instanceof CoreRpcError && error.kind === 'method_not_found';
}

export function isThreadNotFoundCoreRpcError(
error: unknown,
threadId?: string
Expand Down
Loading
Loading