Matchmaking region lock is not per-type and is double-released without ownership check
Location: src/matchmaking/matchmake.service.ts:528 (api)
What: aquireMatchmakeRegionLock/releaseMatchmakeRegionLock use key matchmaking:lock:${region} (line 528), which is not namespaced by match type, and release is a bare del with no ownership token. Two problems: (1) a Competitive matchmake() run for a region holds the lock so a concurrent Wingman/Duel matchmake() for the same region returns early at line 139-144 and never reschedules itself, so the second type's queue is skipped for that cycle. (2) In matchmake(), the lock is released in the Promise.all().finally() at line 237-239 (fire-and-forget, not awaited), and then released AGAIN at line 247 in the totalPlayerNotQueued < ExpectedPlayers branch; the second del can delete a lock that a different matchmake() run has since acquired.
Impact: Region us-east: a Wingman matchmake completes its groups with fewer players than required, hits line 246-247, and calls releaseMatchmakeRegionLock a second time; meanwhile a Competitive matchmake for us-east had just re-acquired the same key. The second del removes Competitive's lock, letting a third matchmake run start and process the same region concurrently. Per-lobby claimLobby limits corruption, but overlapping region runs waste work and can prematurely expand/reschedule search; separately, a type whose cycle lost the lock sits unmatched until the next join/cancel event retriggers it.
Suggested fix: Namespace the lock as matchmaking:lock:${type}:${region}, remove the redundant release at line 247 (rely on the finally), and release with an ownership token (compare-and-del via Lua) so a run only clears its own lock.
Verifier evidence
src/matchmaking/matchmake.service.ts:528 const lockKey = \matchmaking:lock:${region}`;(not namespaced by type). src/matchmaking/matchmake.service.ts:139-143if (!lock) { this.logger.warn(...); return; }(early return, no reschedule). src/matchmaking/matchmake.service.ts:237-239const results = await Promise.all(createMatchesPromises).finally(() => { void this.releaseMatchmakeRegionLock(region); });. src/matchmaking/matchmake.service.ts:246-248 if (totalPlayerNotQueued < ExpectedPlayers[type]) { await this.releaseMatchmakeRegionLock(region); return; }(second release). src/matchmaking/matchmake.service.ts:539-542private async releaseMatchmakeRegionLock(region: string) { const lockKey = `matchmaking:lock:${region}`; await this.redis.del(lockKey); }(bare del, no ownership token). src/matchmaking/matchmaking.gateway.ts:249-251for (const region of regions) { void this.matchmakeService.matchmake(type, region); }` (per-type trigger).
createMatch performs non-transactional multi-step writes with no error handling in the confirm path
Location: src/matchmaking/matchmake.service.ts:817 (api)
What: createMatch() (line 817) executes a sequence of non-atomic steps: createMatchBasedOnType (creates match + reserves a server), two separate insert_match_lineup_players mutations for team1 and team2 (lines 839-861), updateMatchStatus Live (863), then writes redis confirmation/matchId keys. It is called from playerConfirmMatchmaking() (line 814) which has no try/catch, and the gateway handler playerConfirmation() (matchmaking.gateway.ts:300) also has none, so any mid-sequence failure throws out to the socket framework with no compensation.
Impact: If the team2 insert_match_lineup_players mutation (line 851) fails transiently (Hasura hiccup) after team1 was inserted and a server was already reserved, the match row exists with only one team's players, is never set Live, the reserved server stays pinned to the orphan match, and the confirmation hash/lobbies are left in place with the cancel job already cancelled or about to expire. Players are stuck: the confirmation can no longer complete and the reserved server is not freed.
Suggested fix: Wrap the match+lineup+status writes in a single Hasura transaction (or a create-with-nested-lineups mutation) and add try/catch in createMatch() that, on failure, tears down the partial match, releases the reserved server, and requeues the lobbies.
Verifier evidence
matchmake.service.ts:821 await this.removeCancelMatchMakingJob(confirmationId); (safety-net removed before writes). matchmake.service.ts:839-861 two separate await this.hasura.mutation({ insert_match_lineup_players... }) calls with no wrapping transaction/try-catch. matchmake.service.ts:863 await this.matchAssistant.updateMatchStatus(match.id, "Live");. matchmake.service.ts:814 await this.createMatch(confirmationId); inside playerConfirmMatchmaking (no try/catch). matchmaking.gateway.ts:314-317 await this.matchmakeService.playerConfirmMatchmaking(confirmationId, user.steam_id); (no try/catch). REFUTING the server-reservation claim: match-assistant.service.ts:1483-1531 createMatchBasedOnType returns insert_matches_one with no server write; match-assistant.service.ts:224-238 updateMatchStatus only does update_matches_by_pk _set status. Server assignment is separate: match-assistant.service.ts:278 const isAssignedOnDemand = await this.assignOnDemandServer(matchId); and reservation via update_servers_by_pk reserved_by_match_id at 704.
Found by the 2026-07 multi-agent code audit; adversarially verified (CONFIRMED, P2). One of a batch of findings from that pass.
Matchmaking region lock is not per-type and is double-released without ownership check
Location:
src/matchmaking/matchmake.service.ts:528(api)What: aquireMatchmakeRegionLock/releaseMatchmakeRegionLock use key
matchmaking:lock:${region}(line 528), which is not namespaced by match type, and release is a baredelwith no ownership token. Two problems: (1) a Competitive matchmake() run for a region holds the lock so a concurrent Wingman/Duel matchmake() for the same region returns early at line 139-144 and never reschedules itself, so the second type's queue is skipped for that cycle. (2) In matchmake(), the lock is released in the Promise.all().finally() at line 237-239 (fire-and-forget, not awaited), and then released AGAIN at line 247 in thetotalPlayerNotQueued < ExpectedPlayersbranch; the second del can delete a lock that a different matchmake() run has since acquired.Impact: Region us-east: a Wingman matchmake completes its groups with fewer players than required, hits line 246-247, and calls releaseMatchmakeRegionLock a second time; meanwhile a Competitive matchmake for us-east had just re-acquired the same key. The second del removes Competitive's lock, letting a third matchmake run start and process the same region concurrently. Per-lobby claimLobby limits corruption, but overlapping region runs waste work and can prematurely expand/reschedule search; separately, a type whose cycle lost the lock sits unmatched until the next join/cancel event retriggers it.
Suggested fix: Namespace the lock as
matchmaking:lock:${type}:${region}, remove the redundant release at line 247 (rely on the finally), and release with an ownership token (compare-and-del via Lua) so a run only clears its own lock.Verifier evidence
src/matchmaking/matchmake.service.ts:528
const lockKey = \matchmaking:lock:${region}`;(not namespaced by type). src/matchmaking/matchmake.service.ts:139-143if (!lock) { this.logger.warn(...); return; }(early return, no reschedule). src/matchmaking/matchmake.service.ts:237-239const results = await Promise.all(createMatchesPromises).finally(() => { void this.releaseMatchmakeRegionLock(region); });. src/matchmaking/matchmake.service.ts:246-248if (totalPlayerNotQueued < ExpectedPlayers[type]) { await this.releaseMatchmakeRegionLock(region); return; }(second release). src/matchmaking/matchmake.service.ts:539-542private async releaseMatchmakeRegionLock(region: string) { const lockKey = `matchmaking:lock:${region}`; await this.redis.del(lockKey); }(bare del, no ownership token). src/matchmaking/matchmaking.gateway.ts:249-251for (const region of regions) { void this.matchmakeService.matchmake(type, region); }` (per-type trigger).createMatch performs non-transactional multi-step writes with no error handling in the confirm path
Location:
src/matchmaking/matchmake.service.ts:817(api)What: createMatch() (line 817) executes a sequence of non-atomic steps: createMatchBasedOnType (creates match + reserves a server), two separate insert_match_lineup_players mutations for team1 and team2 (lines 839-861), updateMatchStatus Live (863), then writes redis confirmation/matchId keys. It is called from playerConfirmMatchmaking() (line 814) which has no try/catch, and the gateway handler playerConfirmation() (matchmaking.gateway.ts:300) also has none, so any mid-sequence failure throws out to the socket framework with no compensation.
Impact: If the team2 insert_match_lineup_players mutation (line 851) fails transiently (Hasura hiccup) after team1 was inserted and a server was already reserved, the match row exists with only one team's players, is never set Live, the reserved server stays pinned to the orphan match, and the confirmation hash/lobbies are left in place with the cancel job already cancelled or about to expire. Players are stuck: the confirmation can no longer complete and the reserved server is not freed.
Suggested fix: Wrap the match+lineup+status writes in a single Hasura transaction (or a create-with-nested-lineups mutation) and add try/catch in createMatch() that, on failure, tears down the partial match, releases the reserved server, and requeues the lobbies.
Verifier evidence
matchmake.service.ts:821
await this.removeCancelMatchMakingJob(confirmationId);(safety-net removed before writes). matchmake.service.ts:839-861 two separateawait this.hasura.mutation({ insert_match_lineup_players... })calls with no wrapping transaction/try-catch. matchmake.service.ts:863await this.matchAssistant.updateMatchStatus(match.id, "Live");. matchmake.service.ts:814await this.createMatch(confirmationId);inside playerConfirmMatchmaking (no try/catch). matchmaking.gateway.ts:314-317await this.matchmakeService.playerConfirmMatchmaking(confirmationId, user.steam_id);(no try/catch). REFUTING the server-reservation claim: match-assistant.service.ts:1483-1531 createMatchBasedOnType returns insert_matches_one with no server write; match-assistant.service.ts:224-238 updateMatchStatus only does update_matches_by_pk _set status. Server assignment is separate: match-assistant.service.ts:278const isAssignedOnDemand = await this.assignOnDemandServer(matchId);and reservation via update_servers_by_pk reserved_by_match_id at 704.Found by the 2026-07 multi-agent code audit; adversarially verified (CONFIRMED, P2). One of a batch of findings from that pass.