---
title: "Capturando uma Race Condition de Retry com Uma Seed: Simulação Determinística em Rust usando turmoil"
url: https://tiarebalbi.com/pt-br/blog/catching-retry-race-deterministic-simulation-rust-turmoil
markdown: https://tiarebalbi.com/pt-br/blog/catching-retry-race-deterministic-simulation-rust-turmoil.md
description: "Reconstruí um teste de retry flaky em Rust usando turmoil. Uma seed de 8 bytes reproduziu a race de partição byte por byte. Notas sobre o que DST captura e o que escapa."
author: "Tiarê Balbi Bonamini"
locale: pt-br
published: 2026-06-04
updated: 2026-06-04
category: "Backend"
tags: ["rust", "tokio", "turmoil", "dst", "sistemas-distribuídos", "testes", "testes-flaky"]
translation: https://tiarebalbi.com/en/blog/catching-retry-race-deterministic-simulation-rust-turmoil
---
# Capturando uma Race Condition de Retry com Uma Seed: Simulação Determinística em Rust usando turmoil

A maioria dos testes flaky não me ensina nada. A linha de saída diz "esperava 1, obteve 2" uma vez a cada 4.000 execuções, o screenshot do CI sai do dashboard e a próxima pessoa a tocar no arquivo faz rebase por cima. Eu tenho anotações de três deles do último ano — todos sobre retries durante partição, todos em Rust, todos irreproduzíveis no meu laptop.

Isso mudou quando me sentei com o `turmoil`. É o framework de simulação determinística do próprio Tokio: cada host roda em uma thread, o tempo e a rede são mockados, e toda a simulação é dirigida por um RNG com seed. A promessa é a parte que torna essa categoria de bug interessante. Uma única seed de 8 bytes replica as mesmas decisões de scheduling, os mesmos timings de partição, a mesma ordem de bytes do TCP. O flake deixa de ser uma história e se torna um identificador.

O teste de simulação determinística deixou de ser folclore do FoundationDB. Uma palestra na QCon London 2026 percorreu uma DST baseada em máquina de estados em Rust, a WarpStream publicou sobre rodar todo o seu SaaS através do Antithesis em março, e o time da S2 publicou um artigo sobre combinar `turmoil` com shims libc para código de armazenamento em Rust. Este post é o que aprendi montando um pequeno exemplo, mais os vazamentos que tive que tampar antes que ele realmente se sustentasse.

## O que o turmoil simula e o que ele não simula

Um teste turmoil constrói um `Sim`, registra alguns "hosts" (cada um é uma closure async), registra um "client" (o driver do teste) e chama `sim.run()`. Os hosts recebem TCP/UDP virtual via `turmoil::net`, hostnames virtuais e um tempo do tokio que também é mockado. O simulador tem um loop de passos. A cada passo, a rede entrega quaisquer pacotes que estejam vencidos, quaisquer timers que estejam vencidos disparam, e qualquer task que esteja pronta progride. Com uma seed fixa, a ordem é totalmente determinada. A crate atual é `turmoil = "0.7"`, que adiciona um shim parcial de filesystem atrás de `unstable-fs` para testes de consistência em crash.

O que o turmoil não controla é qualquer coisa que escape de sua superfície: syscalls reais, threads criadas fora do runtime, bibliotecas que mantêm seus próprios clocks, qualquer coisa apoiada em `std::collections::HashMap`. O `HashMap` do Rust faz seed do seu hasher por processo a partir do `OsRng` na construção, então a ordem de iteração muda entre execuções mesmo quando o resto do código é determinístico. Perdi uma tarde com isso.

## Uma race condition de retry que sobrevive a 10.000 reexecuções

A forma do bug que quero que um teste encontre é pequena. Um cliente envia uma requisição "apply 42" para um servidor. O servidor aplica, incrementa um contador e responde com `"ok"`. A rede descarta o ack. O cliente faz retry. O servidor aplica "42" de novo. O bug é a falta de checagem de idempotência. O flake é que ele só dispara quando o timing da partição se alinha com o timer de retry da aplicação.

A forma, desenhada, fica assim:

![](https://rso2zax703psuq0y.public.blob.vercel-storage.com/postscontent/1777819132909-n61ujpnj5g7.jpeg)

Aqui está o menor teste turmoil que consegui escrever que fixa o problema.

```rust
// Cargo.toml
// [dependencies]
// tokio = { version = "1", features = ["full"] }
// turmoil = "0.7"
// rand = "0.9"
//
// Run with: cargo test --release retry_race -- --nocapture

use std::sync::{Arc, atomic::{AtomicU32, Ordering}};
use std::time::Duration;

use rand::SeedableRng;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use turmoil::{net::{TcpListener, TcpStream}, Builder};

fn run_one(seed: u64) -> u32 {
    let applied = Arc::new(AtomicU32::new(0));
    let mut sim = Builder::new()
        .simulation_duration(Duration::from_secs(30))
        .build_with_rng(Box::new(rand::rngs::StdRng::seed_from_u64(seed)));

    let counter = applied.clone();
    sim.host("server", move || {
        let counter = counter.clone();
        async move {
            let listener = TcpListener::bind("0.0.0.0:80").await?;
            loop {
                let (mut s, _) = listener.accept().await?;
                let counter = counter.clone();
                tokio::spawn(async move {
                    let mut id = [0u8; 4];
                    if s.read_exact(&mut id).await.is_ok() {
                        // BUG: no dedupe on id — every arrival applies.
                        counter.fetch_add(1, Ordering::Relaxed);
                        let _ = s.write_all(b"ok").await;
                    }
                });
            }
        }
    });

    sim.client("client", async {
        let id: u32 = 42;
        for attempt in 0..2 {
            let mut s = TcpStream::connect("server:80").await?;
            s.write_all(&id.to_le_bytes()).await?;
            if attempt == 0 { turmoil::partition("client", "server"); }
            let mut ack = [0u8; 2];
            let _ = tokio::time::timeout(
                Duration::from_secs(2),
                s.read_exact(&mut ack),
            ).await;
            turmoil::repair("client", "server");
        }
        Ok(())
    });

    sim.run().unwrap();
    applied.load(Ordering::Relaxed)
}

#[test]
fn retry_race() {
    for seed in 0..32 {
        let n = run_one(seed);
        assert!(n <= 1, "seed={seed} applied={n}: not idempotent");
    }
}
```

Execute com `cargo test --release retry_race -- --nocapture`. A primeira seed que falha imprime `applied=2` e o assert para o teste. A correção é nada surpreendente — manter um `HashSet<u32>` de ids aplicados no servidor e pular o incremento quando o id já estiver presente. O ponto não é a correção. O ponto é que a falha é a mesma na primeira execução, na milionésima execução, no meu laptop, no CI, em um M3 emprestado, e permanece a mesma enquanto eu segurar a seed.

As duas linhas não óbvias são `build_with_rng(...)`, que faz cada fonte de aleatoriedade que o simulador possui ser derivada da `seed`, e `partition("client", "server")` imediatamente depois do write. Os bytes de "42" podem ou não ter cruzado para o servidor antes que essa partição entre em vigor. Com a variância da seed, ambos os resultados acontecem ao longo das 32 execuções. A invariante que o teste verifica (`applied <= 1`) captura o caminho ruim sem precisar prever quais seeds o expõem.

## O que a seed realmente fixa

Uma execução do turmoil passa um único RNG por toda escolha que o simulador faz — ordem de scheduling de tasks, jitter de entrega de pacotes, o timing que as primitivas de manipulação de rede usam. O tempo do tokio também é mockado, então `tokio::time::timeout` retorna no mesmo instante lógico para a mesma seed. Isso é suficiente para tornar a race de timing de partição acima estável entre execuções.

É também onde a técnica se vende abaixo do que entrega. A seed fixa as escolhas do simulador, não as do seu programa. Se o código chama `std::time::Instant::now`, ele vê tempo real do relógio de parede e o teste deixa de ser determinístico. O mesmo vale para `getrandom`, `quanta`, `rdtsc`, qualquer coisa que busca entropia ou tempo fora do tokio. O writeup do time da S2 sobre combinar turmoil com uma camada de shimming libc (a derivada deles do `madsim` chamada `mad-turmoil`) foi o primeiro lugar onde vi isso explicado de forma clara: o ecossistema Rust tem tantas crates transitivas puxando tempo e aleatoriedade por fora que "determinístico" precisa de uma costura no nível do libc, não só no nível do runtime.

Para o meu próprio código, mantenho a regra curta. Passe um `Clock` e um `Rng` por trait, troque-os por versões determinísticas nos testes, nunca chame `Instant::now` de qualquer lugar que `cargo test` alcance. Não pega todo vazamento — uma iteração de `HashMap` transitiva ainda morde — mas é os 80% mais baratos do ganho.

## O que isso não é

Turmoil não é chaos engineering. Chaos roda contra sistemas com formato de produção e expõe problemas com o deployment, com o monitoramento e com as pessoas. DST roda em um único processo em uma única thread e expõe problemas com o protocolo — o tipo de problema em que a pergunta inteira é "o que acontece se essas duas mensagens chegarem nesta ordem exata e este pacote for descartado primeiro?". Os dois respondem perguntas diferentes. O farm VOPR do projeto TigerBeetle roda em 1.000 cores 24/7 e acelera o tempo simulado em aproximadamente 700x — eles pegam bugs de protocolo antes dos deploys e depois rodam chaos real em pré-produção para todo o resto.

Eu também evitaria recorrer a ele para código que não tenha uma costura clara de rede. Se um "sistema distribuído" é um pool de conexões Postgres mais um handler HTTP, o tempo vai para encaixar nas traits de `turmoil::net`, não para debugar flake. DST justifica seu peso quando um serviço tem seu próprio protocolo de mensagens, múltiplos participantes e correção dependente de timing — replicação, eleição de líder, retry-com-dedupe, roteamento de sessão sticky, transações distribuídas.

## Uma pequena lista de vazamentos para tampar antes de confiar na seed

* Chamadas diretas para `std::time::Instant::now` e `std::time::SystemTime::now` dentro de qualquer caminho que o teste alcance. Elas passam por cima do tempo mockado do turmoil.
* Ordem de iteração de `HashMap`. O hasher do Rust tem seed do `OsRng` por construção. Use um `BTreeMap`, um `HashMap` com hasher determinístico, ou ordene antes de observar.
* `getrandom`, `OsRng`, qualquer coisa que puxe entropia do `/dev/urandom`. Plumbe o RNG. Não deixe que dependências criem o seu próprio.
* Qualquer coisa que crie threads do SO. O turmoil agenda tasks em sua única thread. Threads fora dela são invisíveis para o simulador.
* Uso direto de `tokio::net` em vez de `turmoil::net`. A costura em tempo de compilação é desconfortável — o padrão usual é um bloco `cfg(feature = "turmoil")` — mas é a linha entre uma rede simulada e chamadas de rede reais escapando do teste.

Quando recorrer ao turmoil: um serviço com seu próprio protocolo, onde retries, partições ou janelas de timing já queimaram um teste flaky que ninguém quer debugar. Quando passar reto: serviços simples de request/response que cabem em um mock e alguns `#[tokio::test]`, ou qualquer coisa em que a classe de bug viva no formato do tráfego de produção e não na ordenação de mensagens.

A conclusão que eu escreveria em um post-it: um teste flaky de sistemas distribuídos não é científico, mas só porque eu ainda não fixei sua seed. O trabalho para tornar um serviço executável sob um simulador com seed é real, e nem todos os serviços pagam de volta. Os que pagam retornam algo melhor que "o teste passou" — eles retornam "o teste falhou na seed 7 e vai continuar falhando na seed 7 até o protocolo estar certo".
