Skip to content

[BUG] CRITICAL: Race condition in account registration — pool and DB can go out of sync #449

@axisrow

Description

@axisrow

Description

In src/web/routes/auth.py, during Telegram account registration the client is added to the in-memory pool (pool.add_client()) before being saved to the database (db.add_account()). If any exception occurs between those two calls, the account exists in the pool but not in the database. After a process restart the account is gone entirely — the session string is lost.

Location

File: src/web/routes/auth.py
Lines: 142–168

Code

try:
    session_string = await auth.verify_code(phone, code, phone_code_hash, password_2fa or None)

    existing = await db.get_accounts()
    is_primary = len(existing) == 0

    await pool.add_client(phone, session_string)   # ← (1) added to in-memory pool

    is_premium = False
    acquired = await pool.get_client_by_phone(phone)   # ← (2) can raise / timeout
    if acquired:
        session, acquired_phone = acquired
        try:
            me = await session.fetch_me()              # ← (3) network call — can fail
            is_premium = bool(getattr(me, "premium", False))
        except Exception as e:
            logger.warning(...)
        finally:
            await pool.release_client(acquired_phone)

    account = Account(...)
    await db.add_account(account)                  # ← (4) only persisted here

Failure scenarios

When it fails Result
fetch_me() raises an unhandled non-Exception (e.g. BaseException, KeyboardInterrupt) Pool has client, DB doesn't
db.add_account() raises (DB locked, disk full, unique constraint) Pool has client, DB doesn't
Process killed (SIGKILL) between steps 1 and 4 Pool state lost on restart; session string never persisted

On restart: ClientPool is rebuilt from the database, so the orphaned in-memory client disappears — the authenticated session is permanently lost even though the user completed the 2FA flow successfully.

Additional problem: is_primary race

is_primary = len(existing) == 0 is computed before pool.add_client(). If two accounts are added concurrently, both may read existing as empty and both will be marked as primary.

Fix

  1. Save to the database first, then add to the pool:
account = Account(phone=phone, session_string=session_string,
                  is_primary=is_primary, is_premium=False)
await db.add_account(account)          # persist first

await pool.add_client(phone, session_string)   # then warm pool

# update is_premium in background / best-effort
  1. If pool insertion fails after a successful DB write, that is recoverable on restart — the inverse (pool ✅, DB ❌) is not.

Severity

CRITICAL — authenticated session strings can be permanently lost, causing invisible account drop without any error shown to the user.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsecurity

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions