Skip to content

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

Description

@C0derTang

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.tsPOST: 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    model:opusComplex logic - opus/fable agent

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions