Repository navigation
fix(replay): validate submissions before taking a replay slot - #11
Merged
Merged
Conversation
tee8z
marked this pull request as ready for review
October 9, 2026 16:16
A score submission took one of the two replay slots as soon as the handler ran and held it across the user and session lookups, base64 decoding and hashing on the async runtime. Submissions that could never replay (unknown session, wrong owner, impossible length) occupied a slot and, under load, got a retryable 503 instead of their 400/403/404. The handler now does the user, session and ownership lookups, the encoded-length-versus-frames check and, with bot detection enabled, the server-side timing checks before admission. Decoding, hashing and replay run together in the blocking job, which hands the decoded log back for storage instead of the handler keeping a second copy. Nothing is written before admission, so a busy 503 stays safe to retry. Rejection statuses and messages are unchanged. The client now backs off exponentially with jitter on 503, waits at least Retry-After, and gives up after the same eleven seconds of waiting, so a busy server receives about half as many re-uploads. Replay rejections are counted by reason and verification time is recorded in a histogram.
The password budget from #10 is a new AppState field.
tee8z
force-pushed
the
fix/replay-admission-order
branch
from
October 9, 2026 17:19
9c9b553 to
e2a4188
Compare
tee8z
marked this pull request as draft
October 9, 2026 17:19
tee8z
marked this pull request as ready for review
October 9, 2026 17:32
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.
Follows #9.
#9 bounded replay memory and concurrency correctly, but
submit_scoretook a replay slot as soon as the handler ran. The slot was held across the user and session lookups, base64 decoding and SHA-256 hashing on the async runtime. A submission that could never replay (unknown session, wrong owner, impossible length) occupied one of the two slots. Under load, it got a retryable 503 instead of its 400/403/404.What changed
Admission order (
domain/games/routes.rs). The handler now does these checks beforeadmit():check_encoded_length, which compares the base64 string length with the frame count without decoding.STANDARDrequires canonical padding, so the encoded length of a valid log is exact. The frame cap applies here too.analyze_server_timingand the client-timing cross-reference). It needs only the session row, the request and the clock.The IP-activity query and the client frame-timing analysis still run after the replay, as before. Nothing is written before admission, so a busy 503 stays safe to retry. Every rejection keeps its status and message. Two things changed as a result:
server_elapsedis now measured when the submission arrives, not after its replay.Decode, hash and replay in the blocking job (
verify.rs).ReplayPermit::verifynow takes the base64 log and the claimed hash, and decodes, hashes and replays on the blocking worker. The encoded string is freed before the replay starts. The decoded bytes come back with the result, so the handler no longer clones them (input_bytes.clone()is gone).ReplayErrorgainedInvalidEncodingandHashMismatch. They return the same 400 messages as before, and a hash mismatch still counts as a rejected score.Client backoff (
game_canvas.js). On 503, retries back off exponentially with jitter (1 s, 2 s, then 4 s, each scaled by 0.5–1×). Each retry waits at leastRetry-After. Retries stop after the same 11 s of total waiting, so a busy server gets about 4–7 re-uploads instead of 11.Metrics (
metrics.rs). The new metrics are:proofofscore_replay_rejections_total{reason}, with every reason pre-initialised: busy, invalid_input, invalid_encoding, hash_mismatch, timed_out, worker_failed. It is counted where each rejection happens:admit,check_encoded_lengthandverify.proofofscore_replay_verification_seconds, a histogram of job time with the default buckets, which fit the 10 s budget.docs/score-integrity.mddescribes the admission order and the client backoff.Tests
routes::tests::busy_verification_answers_503_before_writing_anything(router over a real socket and in-memory SQLite, with both replay slots held):retry-after: 1, and no rows appear inscores,game_input_logsorscore_metadata.routes::tests::invalid_submissions_get_their_4xx_while_verification_is_busy: with both slots held, an unknown session, another player's session and an impossible length get 404, 403 and 400 with their usual messages, and nothing is written.verify::tests::encoded_length_check_rejects_logs_that_cannot_hold_the_frames:verify::tests::undecodable_or_mismatched_logs_keep_their_400_responses: bad base64 and a wrong hash, now detected inside the job, keep their 400 status and messages.verify::tests::admission_survives_cancellation_and_recovers_from_worker_failure: the spin onavailable_permits()+yield_nowis replaced with a boundedtimeout(5 s, acquire_many_owned(2)). This proves that both the failed worker's slot and the cancelled caller's slot come back.metrics::tests::renders_expected_familiescovers the new families.scripts/test-score-submission.cjsuses a stubbedMath.randomand checks:Retry-Aftersets the shortest waitThe router tests run over a socket, like the metrics listener tests, because calling the router directly would need
toweras a new dev-dependency.Open questions for the owner
docs/score-integrity.mdsays "one input log per session", butsave_input_logupserts onsession_id, andsubmit_scoreinserts a newscoresrow on every accepted submission. A session can therefore be submitted more than once: each submission adds a score, and the stored log is replaced. This PR does not change that. Should a session accept only one score?start_timeasOffsetDateTime::to_string()(2026-10-09 12:00:00.0 +00:00:00). The handler parses it withIso8601::DEFAULT, which requires aTseparator, so the parse fails. As a result,analyze_server_timingand the client-timing cross-reference are skipped andserver_elapsed_secsis stored as 0.update_session_activityhas the same parse. This PR keeps the block as it is, now before admission, and adds no test that depends on it. Fixing the format would turn on 403s for real players. In particular, the cross-reference rejects when the server saw more than twice the time the client reports, and that includes time spent before the game starts. That needs a deliberate decision.Deliberately not changed
AAAAfor 3 frames) still takes a slot and then gets the same 400 from the decoded-length check inside the job. Only a non-browser client can produce this.