fix(race): false absence forfeit — 300s grace, CF-activity fallback, freeze beacon - #305
Open
C0derTang wants to merge 1 commit into
Open
fix(race): false absence forfeit — 300s grace, CF-activity fallback, freeze beacon#305C0derTang wants to merge 1 commit into
C0derTang wants to merge 1 commit into
Conversation
…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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #303
What changed
Layer 1 — grace constants
src/lib/types.ts:ABSENCE_FORFEIT_SEC60 → 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_SEC35 → 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.tsmaybeAbsenceForfeit: before forfeiting, queriesmax(submitted_at)fromrace_submissionsfor(race.id, opponentId)and decides onlastSeenMs = 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 bothDateand raw-string driver returns for the baremax()projection.src/components/race/RaceRoom.tsx: the absence-countdown banner mirrors the samemax(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
src/app/api/races/[id]/heartbeat/route.ts: POST, mirrors the poll route's ordering —enforcePolicykeyed on cheap Clerk id first (reuses the existingracePollpolicy; beacons are a handful per race vs the ~10/min poll cadence, so the shared bucket has ample headroom), thenrequireLinkedUser, race load,isParticipant,stampHeartbeat(id, callerIsP1)(no-ops unless active), 204, no body parsing.RaceRoom.tsx: one effect active only whilestatus === "active"— onfreezeandvisibilitychange→hidden,navigator.sendBeacon('/api/races/'+raceId+'/heartbeat')(cookies included, survives freeze); onresume/pageshow/visibility→visible, immediate snapshot refetch + immediate poll tick (via apollNowRefpopulated 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 .— cleanpnpm exec tsc --noEmit— cleanpnpm 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 thetests/race-finish.test.tspattern;finishRacemocked 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)
maybeAbsenceForfeit, and the new heartbeat route makes NO forfeit decision (it only stamps the authenticated caller's own side).pN_last_seen_at.finishRaceidempotency untouched (no changes tosrc/lib/race/finish.ts).src/lib/db/schema.ts;src/lib/types.tschange is the constant value + comment only.presence.tshelpers remain pure and signature-identical — only callers' inputs widened.Notes / deviations
max()is monotone; a test pins the short-circuit).racePollpolicy bucket rather than adding a new policy, per the spec's "reuse a suitable existing policy" option.🤖 Generated with Claude Code