From 113896e37328eb2e3cc26fbd85d8fe449cb04f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20G=C3=B3mez?= Date: Sat, 8 Aug 2026 18:43:34 -0400 Subject: [PATCH] fix(session): retry session init once and report the failure instead of going silent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit initializeSession() and startFreshSession() caught their own errors with a console.error and returned session: null — no retry, no server-side trace. Every subsequent activity_logs save then no-ops on the null session check, invisibly. This is what happened to pid 54 on 2026-08-07: a session-init call failed once during the prepare->teach transition and the rest of their teach phase (code, oracle, chat, knowledge updates) was never logged, with no visible error to the participant or a researcher watching live. Both call sites now retry once after a short delay via a small, tested retryOnce() helper, and report a final failure through the existing (but previously unwired) reportLogFailure endpoint, so a recurrence shows up in the backend logs immediately instead of requiring a manual activity_logs audit days later. Claude-Session: https://claude.ai/code/session_01QdvEMCZypQR8SePXJiY22C --- frontend/context/sessioncontext.tsx | 45 ++++++++++++---- frontend/src/utils/retryOnce.test.ts | 77 ++++++++++++++++++++++++++++ frontend/src/utils/retryOnce.ts | 27 ++++++++++ 3 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 frontend/src/utils/retryOnce.test.ts create mode 100644 frontend/src/utils/retryOnce.ts diff --git a/frontend/context/sessioncontext.tsx b/frontend/context/sessioncontext.tsx index 271e560..2192efd 100644 --- a/frontend/context/sessioncontext.tsx +++ b/frontend/context/sessioncontext.tsx @@ -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 @@ -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); @@ -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); } @@ -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 @@ -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(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); } diff --git a/frontend/src/utils/retryOnce.test.ts b/frontend/src/utils/retryOnce.test.ts new file mode 100644 index 0000000..4624adf --- /dev/null +++ b/frontend/src/utils/retryOnce.test.ts @@ -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); + }); +}); diff --git a/frontend/src/utils/retryOnce.ts b/frontend/src/utils/retryOnce.ts new file mode 100644 index 0000000..c60289e --- /dev/null +++ b/frontend/src/utils/retryOnce.ts @@ -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( + attempt: () => Promise, + fallback: T, + onFinalFailure: (error: unknown) => void, + delayMs = 1500, +): Promise { + try { + return await attempt(); + } catch { + await new Promise(resolve => setTimeout(resolve, delayMs)); + try { + return await attempt(); + } catch (error) { + onFinalFailure(error); + return fallback; + } + } +}