Skip to content

[Critical] Non-transactional vote status update in useRoom races with Firestore transaction causing vote state corruption #718

@AnushKamble

Description

@AnushKamble

What

The deadlock-resolution effect in src/hooks/useRoom.js (lines 491-504) uses a plain Firestore updateDoc call to resolve vote consensus when users leave mid-vote, instead of a Firestore runTransaction. This races with the castVote function (lines 360-408) which correctly uses runTransaction, leading to non-deterministic vote state corruption.

Cause

The castVote function uses a Firestore transaction that atomically reads the room document, modifies the full activeVote map, and writes it back:

// Lines 360-408 — castVote uses transaction (correct)
await runTransaction(db, async (transaction) => {
  const roomDoc = await transaction.get(roomRef);
  const data = roomDoc.data();
  const activeVote = { ...data.activeVote };
  // ... modify approvals/rejections/status
  transaction.update(roomRef, { activeVote });  // writes entire map atomically
});

But the deadlock-resolution effect (lines 491-504) uses a plain updateDoc with a field-path write:

// Lines 491-504 — deadlock fix uses plain updateDoc (races with transaction)
useEffect(() => {
  if (vote.status === 'voting' && vote.initiatorUid === user?.uid) {
    if (vote.approvals?.length > activeCount / 2) {
      updateDoc(doc(db, 'rooms', roomId), { 'activeVote.status': 'approved' })
        .catch(console.error);  // partial field update — no transaction!
    }
  }
}, [roomId, roomData?.activeVote, roomData?.activeUsers, user?.uid]);

Race scenario:

  1. User A's approval causes castVote transaction to begin (reads snapshot where approvals=2, activeCount=2).
  2. User B leaves the room while transaction is in-flight.
  3. The effect fires, sees approvals(2) > activeCount(1)/2, calls updateDoc setting status to approved.
  4. The effect's updateDoc commits.
  5. The transaction from step 1 commits, writing the entire activeVote map based on its stale snapshot, potentially reverting status to what it read at step 1.

Additionally, mixing updateDoc (partial map write) with transaction.update (full map write) creates inconsistent writes: one overwrites just status, the other overwrites the entire activeVote including approvals, rejections, and status.

Fix

Replace the updateDoc call with a Firestore transaction that double-checks the vote is still in voting state before advancing:

  useEffect(() => {
    if (!roomId || !roomData?.activeVote) return;
    const vote = roomData.activeVote;
    const activeCount = roomData.activeUsers?.length || 1;

    if (vote.status === 'voting' && vote.initiatorUid === user?.uid) {
      if (vote.approvals?.length > activeCount / 2) {
-       updateDoc(doc(db, 'rooms', roomId), { 'activeVote.status': 'approved' }).catch(
-         console.error
-       );
+       const roomRef = doc(db, 'rooms', roomId);
+       runTransaction(db, async (transaction) => {
+         const snap = await transaction.get(roomRef);
+         if (!snap.exists()) return;
+         const current = snap.data().activeVote;
+         if (current && current.status === 'voting' && current.initiatorUid === user?.uid) {
+           const freshActiveCount = (snap.data().activeUsers || []).length;
+           if ((current.approvals || []).length > freshActiveCount / 2) {
+             current.status = 'approved';
+             transaction.update(roomRef, { activeVote: current });
+           }
+         }
+       }).catch(console.error);
      }
    }
  }, [roomId, roomData?.activeVote, roomData?.activeUsers, user?.uid]);

Additional Context

This is a subtle distributed-consensus race condition that only manifests in multi-user collaborative scenarios with specific timing (a user leaves while a vote is being counted). It requires understanding Firestore's transaction model, the difference between partial field-path writes and full-document writes, and the asynchronous interleaving of React effects with Firestore operations. The castVote function (lines 360-408) already demonstrates the correct transaction pattern — the deadlock-resolution effect simply needs to use the same approach.

Metadata

Metadata

Assignees

Labels

gssocOfficial GSSoC '26 issue tagtype:bugVulnerability or logical bug fixestype:designTactile visual design and UI alignmentstype:docsDocumentation and guide upgradestype:featureNew functional feature additions

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions