Problem
A player who backgrounds the race tab to submit on codeforces.com (the NORMAL flow — there is no in-platform submission) gets forfeited: the client polls every 6s via chained setTimeout (RaceRoom.tsx), Chrome throttles background-tab timers to ~1/min, and ABSENCE_FORFEIT_SEC = 60 with a strict > means heartbeat gaps of 61-75s trigger maybeAbsenceForfeit — the actively-solving player loses without a submission. Chrome can also freeze/discard the tab entirely (Battery/Memory Saver), stopping heartbeats altogether.
Fix — three layers
1. Grace constants
src/lib/types.ts: ABSENCE_FORFEIT_SEC 60 → 300. Rewrite the doc comment with the real constraint: users must background the tab to submit on codeforces.com; Chrome throttles background timers to ~1/min; the grace must comfortably exceed the throttled heartbeat cadence.
src/lib/race/presence.ts: ABSENCE_COUNTDOWN_AFTER_SEC 35 → 240 (banner escalates for the last ~60s of grace). Update comment.
tests/presence.test.ts: add a regression test — a 75s heartbeat gap (throttled-tab cadence) must NOT forfeit (isAbsenceForfeit(...) === false), with a comment noting this is the case that produced a false forfeit at 60s. Existing tests are constant-relative and must pass unchanged.
2. CF-activity fallback (server)
A recent Codeforces submission proves presence even with a dead heartbeat. pollActiveRace upserts race_submissions (with submittedAt) in the SAME request, before maybeAbsenceForfeit runs (src/app/api/races/[id]/poll/route.ts).
- In
maybeAbsenceForfeit (src/lib/race/poll.ts): before deciding, query max(submitted_at) from race_submissions where race_id = race.id AND user_id = <opponent>; use lastSeenMs = max(heartbeatMs, latestSubmissionMs) (either may be null). Keep src/lib/race/presence.ts helpers pure and unchanged — only the caller's input changes. Update doc comments.
- Client banner coherence: the absence-countdown banner in
RaceRoom.tsx computes from the opponent heartbeat in the snapshot; apply the same max(lastSeenAt, latest opponent submission submittedAt) using the snapshot's submissions feed so the banner never threatens a forfeit the server won't fire. Display-only.
- Tests: heartbeat stale but recent opponent CF submission ⇒ no forfeit + no finish; both stale ⇒ forfeit. Follow the existing DB-mocking pattern used in
tests/race-finish.test.ts.
3. Freeze beacon (new route + client)
- New route
src/app/api/races/[id]/heartbeat/route.ts — POST: rate-limit first via enforcePolicy keyed on cheap Clerk id (mirror the poll route's pattern; reuse an existing suitable policy or the poll policy), then requireLinkedUser, load race, isParticipant check, stampHeartbeat(id, callerIsP1) (already exported from src/lib/race/poll.ts; no-ops unless race is active). Return 204. No body parsing (sendBeacon sends opaque payloads).
- Client (
src/components/race/RaceRoom.tsx), one effect active only while status === "active": on freeze (Page Lifecycle API) and visibilitychange → hidden, call navigator.sendBeacon('/api/races/' + raceId + '/heartbeat') (cookies included ⇒ Clerk auth works; sendBeacon survives freeze/unload). On resume / pageshow / visibility → visible: trigger an immediate snapshot refetch + poll tick so a returning tab re-proves presence immediately.
Invariants (reviewer will scrutinize)
- Forfeit decision stays server-side and participant-poll-only (cron never forfeits).
- Caller can still never forfeit themselves; only the opponent's staleness is judged.
finishRace idempotency untouched.
- No changes to
src/lib/db/schema.ts or src/lib/types.ts contract shapes (constant VALUE change only).
Out of scope
Web Worker poll scheduler; any cam/mic/media code (none exists — confirmed).
Problem
A player who backgrounds the race tab to submit on codeforces.com (the NORMAL flow — there is no in-platform submission) gets forfeited: the client polls every 6s via chained
setTimeout(RaceRoom.tsx), Chrome throttles background-tab timers to ~1/min, andABSENCE_FORFEIT_SEC = 60with a strict>means heartbeat gaps of 61-75s triggermaybeAbsenceForfeit— the actively-solving player loses without a submission. Chrome can also freeze/discard the tab entirely (Battery/Memory Saver), stopping heartbeats altogether.Fix — three layers
1. Grace constants
src/lib/types.ts:ABSENCE_FORFEIT_SEC60 → 300. Rewrite the doc comment with the real constraint: users must background the tab to submit on codeforces.com; Chrome throttles background timers to ~1/min; the grace must comfortably exceed the throttled heartbeat cadence.src/lib/race/presence.ts:ABSENCE_COUNTDOWN_AFTER_SEC35 → 240 (banner escalates for the last ~60s of grace). Update comment.tests/presence.test.ts: add a regression test — a 75s heartbeat gap (throttled-tab cadence) must NOT forfeit (isAbsenceForfeit(...) === false), with a comment noting this is the case that produced a false forfeit at 60s. Existing tests are constant-relative and must pass unchanged.2. CF-activity fallback (server)
A recent Codeforces submission proves presence even with a dead heartbeat.
pollActiveRaceupsertsrace_submissions(withsubmittedAt) in the SAME request, beforemaybeAbsenceForfeitruns (src/app/api/races/[id]/poll/route.ts).maybeAbsenceForfeit(src/lib/race/poll.ts): before deciding, querymax(submitted_at)fromrace_submissionswhererace_id = race.id AND user_id = <opponent>; uselastSeenMs = max(heartbeatMs, latestSubmissionMs)(either may be null). Keepsrc/lib/race/presence.tshelpers pure and unchanged — only the caller's input changes. Update doc comments.RaceRoom.tsxcomputes from the opponent heartbeat in the snapshot; apply the samemax(lastSeenAt, latest opponent submission submittedAt)using the snapshot's submissions feed so the banner never threatens a forfeit the server won't fire. Display-only.tests/race-finish.test.ts.3. Freeze beacon (new route + client)
src/app/api/races/[id]/heartbeat/route.ts—POST: rate-limit first viaenforcePolicykeyed on cheap Clerk id (mirror the poll route's pattern; reuse an existing suitable policy or the poll policy), thenrequireLinkedUser, load race,isParticipantcheck,stampHeartbeat(id, callerIsP1)(already exported fromsrc/lib/race/poll.ts; no-ops unless race is active). Return 204. No body parsing (sendBeacon sends opaque payloads).src/components/race/RaceRoom.tsx), one effect active only whilestatus === "active": onfreeze(Page Lifecycle API) andvisibilitychange→ hidden, callnavigator.sendBeacon('/api/races/' + raceId + '/heartbeat')(cookies included ⇒ Clerk auth works; sendBeacon survives freeze/unload). Onresume/pageshow/ visibility → visible: trigger an immediate snapshot refetch + poll tick so a returning tab re-proves presence immediately.Invariants (reviewer will scrutinize)
finishRaceidempotency untouched.src/lib/db/schema.tsorsrc/lib/types.tscontract shapes (constant VALUE change only).Out of scope
Web Worker poll scheduler; any cam/mic/media code (none exists — confirmed).