Skip to content

fix(race): false absence forfeit — 300s grace, CF-activity fallback, freeze beacon - #305

Open
C0derTang wants to merge 1 commit into
mainfrom
issue/303-absence-forfeit-hardening
Open

fix(race): false absence forfeit — 300s grace, CF-activity fallback, freeze beacon#305
C0derTang wants to merge 1 commit into
mainfrom
issue/303-absence-forfeit-hardening

Conversation

@C0derTang

Copy link
Copy Markdown
Owner

Closes #303

What changed

Layer 1 — grace constants

  • src/lib/types.ts: ABSENCE_FORFEIT_SEC 60 → 300, doc comment rewritten around the real constraint (users must background the tab to submit on codeforces.com; Chrome throttles background timers to ~1/min; grace must comfortably exceed the throttled cadence). Constant VALUE only — no contract shape changes.
  • src/lib/race/presence.ts: ABSENCE_COUNTDOWN_AFTER_SEC 35 → 240 (banner escalates for the last ~60s of grace), comment updated. All pure helpers unchanged.
  • tests/presence.test.ts: regression test — a 75s heartbeat gap (throttled-tab cadence) must NOT forfeit, with a comment noting this exact case false-forfeited at 60s. Existing constant-relative tests pass unchanged.

Layer 2 — CF-activity fallback (server + banner coherence)

  • src/lib/race/poll.ts maybeAbsenceForfeit: before forfeiting, queries max(submitted_at) from race_submissions for (race.id, opponentId) and decides on lastSeenMs = max(heartbeatMs, latestSubmissionMs) (either may be null). Optimization that preserves semantics exactly: the query only runs when the heartbeat alone would already forfeit — max() can only push last-seen later, so a fresh heartbeat short-circuits identically without the extra query. Handles both Date and raw-string driver returns for the bare max() projection.
  • src/components/race/RaceRoom.tsx: the absence-countdown banner mirrors the same max(heartbeat, latest opponent submission) from the snapshot's submissions feed — display-only, so the banner never threatens a forfeit the server won't fire.

Layer 3 — freeze beacon

  • New src/app/api/races/[id]/heartbeat/route.ts: POST, mirrors the poll route's ordering — enforcePolicy keyed on cheap Clerk id first (reuses the existing racePoll policy; beacons are a handful per race vs the ~10/min poll cadence, so the shared bucket has ample headroom), then requireLinkedUser, race load, isParticipant, stampHeartbeat(id, callerIsP1) (no-ops unless active), 204, no body parsing.
  • RaceRoom.tsx: one effect active only while status === "active" — on freeze and visibilitychange→hidden, navigator.sendBeacon('/api/races/'+raceId+'/heartbeat') (cookies included, survives freeze); on resume/pageshow/visibility→visible, immediate snapshot refetch + immediate poll tick (via a pollNowRef populated by the existing poll-loop effect, so the wake tick reuses the loop's full logic including failure accounting and rescheduling).

How verified

All three checks pass in the worktree:

  • pnpm exec eslint . — clean
  • pnpm exec tsc --noEmit — clean
  • pnpm exec vitest run — 86 files / 1003 tests passed (a first cold-cache run hit 3 vitest fork-worker startup timeouts on Windows; warm rerun fully green, targeted runs of the touched files also green)

New tests/absence-forfeit.test.ts (DB mocked per the tests/race-finish.test.ts pattern; finishRace mocked so "no finish" is directly assertable): stale heartbeat + recent opponent submission ⇒ no forfeit/no finish; both stale ⇒ forfeit with caller as winner; null heartbeat + no submissions ⇒ forfeit; fresh heartbeat ⇒ no submissions query at all; p2-caller judges only p1; non-active/pre-start no-ops.

Invariants (for reviewers)

  • Forfeit decision stays server-side and participant-poll-only — the cron never calls maybeAbsenceForfeit, and the new heartbeat route makes NO forfeit decision (it only stamps the authenticated caller's own side).
  • Caller can never forfeit themselves: only the opponent's heartbeat/submissions are judged; the route/beacon can only stamp the caller's own pN_last_seen_at.
  • finishRace idempotency untouched (no changes to src/lib/race/finish.ts).
  • No changes to src/lib/db/schema.ts; src/lib/types.ts change is the constant value + comment only.
  • presence.ts helpers remain pure and signature-identical — only callers' inputs widened.

Notes / deviations

  • The submissions query is skipped when the heartbeat alone is fresh (spec said "before deciding, query" — this is a pure optimization with identical semantics since max() is monotone; a test pins the short-circuit).
  • The heartbeat route reuses the racePoll policy bucket rather than adding a new policy, per the spec's "reuse a suitable existing policy" option.

🤖 Generated with Claude Code

…back, freeze beacon (#303)

- ABSENCE_FORFEIT_SEC 60 -> 300 and ABSENCE_COUNTDOWN_AFTER_SEC 35 -> 240:
  backgrounding the tab to submit on codeforces.com is the normal flow, and
  Chrome throttles background timers to ~1/min, so the grace must comfortably
  exceed the throttled heartbeat cadence.
- maybeAbsenceForfeit now treats the opponent's latest observed CF submission
  (race_submissions max(submitted_at)) as proof of presence:
  lastSeenMs = max(heartbeat, latest submission); the query only runs when the
  heartbeat alone would already forfeit. presence.ts helpers stay pure.
- Client banner mirrors the same max() from the snapshot submissions feed so
  it never threatens a forfeit the server won't fire (display-only).
- New participant-only POST /api/races/[id]/heartbeat (rate-limited, 204, no
  body) + RaceRoom effect: sendBeacon on freeze/hidden, immediate snapshot
  refetch + poll tick on resume/pageshow/visible.
- Tests: 75s-gap regression in presence.test.ts; new absence-forfeit.test.ts
  covering fallback keep-alive, both-stale forfeit, fresh-heartbeat
  short-circuit, and opponent-only judgment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cph2h Ready Ready Preview, Comment Jul 25, 2026 11:48pm

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

False absence forfeit: raise grace, CF-activity fallback, freeze beacon

1 participant