Skip to content
Open
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
45 changes: 35 additions & 10 deletions frontend/context/sessioncontext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type SessionEventAction
} from '../src/utils/api';
import { queueFailedLog, retryPendingLogs, getPendingLogCount, getPendingLogs } from '../src/utils/logQueue';
import { retryOnce } from '../src/utils/retryOnce';

interface SessionContextType {
// State
Expand Down Expand Up @@ -166,7 +167,12 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
setIsSessionLoading(true);
setActiveProblemId(problemId);

try {
// The actual session fetch-or-create. Pulled out so it can be attempted
// twice below — a transient network blip here previously meant
// `session: null` for the rest of the visit (see the incident this retry
// and report guard against: 2026-08-07, pid 54 lost the entire teach
// phase silently because this threw once and was never retried or seen).
const attemptInit = async () => {
// Check for existing active session
const existingSession = await getActiveSession(problemId, token);

Expand Down Expand Up @@ -214,12 +220,26 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
if (flushed > 0) refreshFailedCount();

return { session: newSession, resumeData: null };
};

} catch (error) {
console.error('SESSION INIT FAILED:', error);
console.error('Problem ID was:', problemId, '| Token present:', !!token);
console.error('session_id will be null — knowledge tracking disabled for this session');
return { session: null, resumeData: null };
try {
return await retryOnce<{ session: SessionInfo | null; resumeData: SessionResumeData | null }>(
attemptInit,
{ session: null, resumeData: null },
(error) => {
const message = error instanceof Error ? error.message : String(error);
console.error('SESSION INIT FAILED (after retry):', error);
console.error('Problem ID was:', problemId, '| Token present:', !!token);
console.error('session_id will be null — knowledge tracking disabled for this session');
// This is the ONLY durable trace of the failure: the participant sees
// nothing, and no activity_logs row exists to find it later (that is
// exactly what made the 2026-08-07 incident invisible for a day).
// reportLogFailure writes a server-side log line even though there is
// no session_id to attach it to yet.
reportLogFailure('event', `SESSION_INIT_FAILED: ${message}`, problemId, 'none',
{ retried: true }, token).catch(() => {});
},
);
} finally {
setIsSessionLoading(false);
}
Expand All @@ -231,7 +251,7 @@ export default function SessionProvider({ children }: { children: ReactNode }) {

setIsSessionLoading(true);
setActiveProblemId(problemId);
try {
const attemptStart = async () => {
const newSession = await createOrGetSession(problemId, token, true);
setCurrentSession(newSession);
// Update the ref synchronously — logUiEvent may fire against the new
Expand All @@ -240,9 +260,14 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
setSessionStartTime(new Date());
sessionStartTimeRef.current = new Date();
return newSession;
} catch (error) {
console.error('[session] Failed to start fresh session:', error);
return null;
};
try {
return await retryOnce<SessionInfo | null>(attemptStart, null, (error) => {
const message = error instanceof Error ? error.message : String(error);
console.error('[session] Failed to start fresh session (after retry):', error);
reportLogFailure('event', `SESSION_RESET_FAILED: ${message}`, problemId, 'none',
{ retried: true }, token).catch(() => {});
});
} finally {
setIsSessionLoading(false);
}
Expand Down
77 changes: 77 additions & 0 deletions frontend/src/utils/retryOnce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { retryOnce } from './retryOnce';

describe('retryOnce', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });

it('returns the result on a clean first attempt, no delay, no failure report', async () => {
const attempt = vi.fn().mockResolvedValue('ok');
const onFinalFailure = vi.fn();

const result = await retryOnce(attempt, 'fallback', onFinalFailure);

expect(result).toBe('ok');
expect(attempt).toHaveBeenCalledTimes(1);
expect(onFinalFailure).not.toHaveBeenCalled();
});

it('recovers on retry after one transient failure — the data is NOT lost', async () => {
const attempt = vi.fn()
.mockRejectedValueOnce(new Error('transient network blip'))
.mockResolvedValueOnce('recovered');
const onFinalFailure = vi.fn();

const promise = retryOnce(attempt, 'fallback', onFinalFailure);
await vi.runAllTimersAsync();
const result = await promise;

expect(result).toBe('recovered');
expect(attempt).toHaveBeenCalledTimes(2);
expect(onFinalFailure).not.toHaveBeenCalled();
});

it('waits delayMs before the retry, not zero', async () => {
const attempt = vi.fn()
.mockRejectedValueOnce(new Error('fail once'))
.mockResolvedValueOnce('ok');

const promise = retryOnce(attempt, 'fallback', vi.fn(), 1500);

await vi.advanceTimersByTimeAsync(1000);
expect(attempt).toHaveBeenCalledTimes(1); // retry hasn't fired yet

await vi.advanceTimersByTimeAsync(600);
expect(attempt).toHaveBeenCalledTimes(2); // now it has

await promise;
});

it('reports and falls back to the safe default after two failures — nothing throws', async () => {
const secondError = new Error('backend still down');
const attempt = vi.fn()
.mockRejectedValueOnce(new Error('first failure'))
.mockRejectedValueOnce(secondError);
const onFinalFailure = vi.fn();

const promise = retryOnce(attempt, { session: null }, onFinalFailure);
await vi.runAllTimersAsync();
const result = await promise;

expect(result).toEqual({ session: null });
expect(attempt).toHaveBeenCalledTimes(2);
expect(onFinalFailure).toHaveBeenCalledTimes(1);
expect(onFinalFailure).toHaveBeenCalledWith(secondError);
});

it('never calls onFinalFailure more than once even if it is itself slow', async () => {
const attempt = vi.fn().mockRejectedValue(new Error('down'));
const onFinalFailure = vi.fn();

const promise = retryOnce(attempt, null, onFinalFailure);
await vi.runAllTimersAsync();
await promise;

expect(onFinalFailure).toHaveBeenCalledTimes(1);
});
});
27 changes: 27 additions & 0 deletions frontend/src/utils/retryOnce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// A single retry for calls that must never leave the caller with an
// unhandled rejection: session init previously caught its own error,
// logged to the browser console only, and returned a null session with no
// retry and no server-side trace — the 2026-08-07 incident (pid 54 lost an
// entire teach phase to one transient failure) is exactly that shape.
//
// `attempt` is tried once; on failure, tried exactly once more after
// `delayMs`. If both fail, `onFinalFailure` runs (for durable, server-side
// reporting) and `fallback` is returned instead of throwing.
export async function retryOnce<T>(
attempt: () => Promise<T>,
fallback: T,
onFinalFailure: (error: unknown) => void,
delayMs = 1500,
): Promise<T> {
try {
return await attempt();
} catch {
await new Promise(resolve => setTimeout(resolve, delayMs));
try {
return await attempt();
} catch (error) {
onFinalFailure(error);
return fallback;
}
}
}