O lock que nasceu no loop errado — e o browser pool que dizia 'não disponível neste worker'
🕷️ Arachne·

O lock que nasceu no loop errado — e o browser pool que dizia 'não disponível neste worker'

📖 6 min de leitura← Voltar para timeline

⚡ “Browser agent não disponível neste worker”

Entre 08 e 09/08, o Arachne acordava intermitente. Um workflow de scraping rodava, e do nada: browser agent não disponível neste worker. Rodava de novo — funcionava. De novo — falhava.

No journal, o RuntimeError enigmático:

RuntimeError: <asyncio.locks.Lock object at 0x7f...> is bound to a different event loop

Um lock preso a um event loop que não era o do worker. Como um cadeado que foi montado numa porta e depois a fechadura mudou de casa — a chave certa, o lugar errado.

🧠 O contexto: o browser pool compartilha um lock

O Arachne mantém um browser pool (Camoufox + Playwright) — navegadores reutilizados entre scraping e VRT pra não subir um browser novo a cada chamada. Um pool assim precisa de um lock: dois workers não podem usar o mesmo browser ao mesmo tempo.

# O pool, simplificado
_browsers = []          # lista de browsers vivos
_last_gc = 0.0          # último garbage collect

async def _get_browser(headless=True):
    async with _lock:   # protege o acesso ao pool
        # ... acha ou cria um browser

O lock era global, criado no import do módulo:

_lock = asyncio.Lock()   # ❌ criado na hora do import

E aí morava o problema.

🔧 A luta: o event loop que muda de casa

No Python asyncio, um asyncio.Lock() criado fora de um loop fica preso ao loop que estiver rodando naquele momento — ou ao loop atual na primeira vez que é usado.

O Arachne tem dois caminhos de execução:

  • Requests normais (FastAPI) → rodam no event loop principal do processo
  • Workflows agendados (cron scheduler) → rodam via threadpool → cada thread ganha o seu próprio event loop

Quando o módulo era importado no processo principal, o lock nascia preso ao loop principal. Quando o workflow rodava na threadpool e tentava async with _lock, o Python comparava o loop do lock com o loop corrente → RuntimeError: bound to a different event loop.

O sintoma era duplo e confuso:

  • O erro técnico (bound to a different event loop) aparecia em logs internos
  • A mensagem de negócio (“browser agent não disponível neste worker”) aparecia pra quem consumia o workflow

Intermitente porque dependia de quando o import acontecia e em qual thread o workflow caía.

💡 A resolução: lazy init — nascer no loop certo

O fix (commit 457326e, 09/08 02:59): o lock deixa de ser criado no import e passa a ser criado dentro do loop corrente, na primeira vez que é usado.

# ❌ Antes — nascia preso ao loop do import
_lock = asyncio.Lock()

# ✅ Depois — nasce no loop que está rodando agora
_lock = None  # type: Optional[asyncio.Lock]

async def _get_lock() -> asyncio.Lock:
    global _lock
    if _lock is None:
        _lock = asyncio.Lock()   # criado DENTRO do loop corrente
    return _lock

E nos dois pontos de uso:

# async with _lock:            →  async with await _get_lock():

O await _get_lock() roda dentro da coroutine — o asyncio.Lock() é criado no loop certo, o loop que está executando aquele worker. 16 linhas adicionadas, 3 removidas. Só isso.

A história de ontem foi um generator que não fechava a sessão do Postgres. Hoje foi um lock que nascia no loop errado. O padrão é o mesmo: o recurso precisa nascer no contexto onde vai viver.

📊 Métricas

Métrica Valor
Commit 457326e (09/08 02:59)
Arquivo api/app/browser_agent/engine.py
Diff +16 / -3
Usos do lock corrigidos 2 (_maybe_gc, _get_browser)
Erro RuntimeError: bound to a different event loop
Sintoma de negócio “browser agent não disponível neste worker” (intermitente)
Testes do Arachne 2.732

🎯 Aprendizados

  1. Recurso async global = problema de loopasyncio.Lock(), asyncio.Queue() e afins criados no import ficam presos ao loop do processo. Se o código roda em threadpool/outro loop (cron, workers), quebra com “bound to a different event loop”.

  2. Lazy init resolve o ciclo de vida — criar o recurso dentro da coroutine (no 1º uso) garante que ele nasce no loop que vai usá-lo. É o padrão pra qualquer singleton async.

  3. Erro intermitente = pista de contexto — falha que aparece e some é quase sempre dependência de timing: qual loop, qual thread, qual import. O journal com o RuntimeError completo foi o que entregou a causa.

  4. Dois sintomas, uma causa — a mensagem de negócio (“não disponível neste worker”) escondia o erro técnico. Sempre caçar a pilha completa antes de “resolver” o sintoma.

~/lifelog — bash
$cat about.txt
╔══════════════════════════════════════╗
║  Samuel Medeiros                    ║
║  Senior Software Engineer           ║
║  Stack: Python · TypeScript · Rust  ║
║  Projetos: Arachne, Dogwalk,        ║
║            Capivara, TatuEngine      ║
╚══════════════════════════════════════╝
      
$