Testes de brokers

Testando o Last Will do MQTT: simule desconexões abruptas

Teste o Last Will do MQTT simulando quedas com kill -9, processos congelados e cortes de rede; meça quando o will dispara e valide o will delay do MQTT 5.

Atualizado · 9 min de leitura

Testando o Last Will do MQTT: simule desconexões abruptas

Para testar um Last Will do MQTT, você precisa fazer o cliente morrer do jeito errado. O broker só publica o will quando uma conexão termina sem um DISCONNECT correto — então parar o seu app normalmente não prova nada. Mate o processo com kill -9, congele-o com kill -STOP ou corte a rede; depois observe o topic do will a partir de um segundo cliente e anote quando o will chega. Um bom teste também inclui o caso negativo: um desligamento ordenado não pode disparar o will.

Este guia traz um pequeno script de dispositivo com will, um observador, as injeções de falha que acionam cada caminho de código e testes do will delay do MQTT 5.

Quando o broker publica o will

EventoWill publicado?Tempo típico
Queda do processo (kill -9, segfault, OOM kill)SimQuase imediato — o sistema operacional fecha o socket
Processo congelado, socket ainda abertoSimApós 1,5 × keep-alive sem tráfego
Corte de rede, falta de energia, cabo desconectadoSimApós 1,5 × keep-alive (não chega FIN nem RST)
Erro de protocolo, broker fecha a conexãoSimImediato
DISCONNECT ordenado (reason code 0)Não—
DISCONNECT do MQTT 5 com reason 0x04SimImediato (mais o will delay)
MQTT 5 com Will Delay IntervalSim, a menos que o cliente reconecte a tempoExpiração do atraso ou fim da sessão, o que vier primeiro

A linha do keep-alive explica por que "meu will demora uma eternidade" é tão comum: com um keep-alive de 60 segundos, um dispositivo celular morto é reportado depois de uns 90 segundos. Esse é o comportamento correto, não um bug — veja keep alive para escolher o intervalo. O conceito em si está em Last Will do MQTT.

O dispositivo sob teste

Este script em paho-mqtt 2.x se comporta como um dispositivo bem projetado: registra um will retido offline, publica um online retido ao conectar e, ao receber SIGTERM, desliga de forma ordenada (publica offline por conta própria e desconecta limpo). Assim você tem um único processo com um caminho de saída limpo e vários sujos.

device.py
import os, signal, sys, threading
import paho.mqtt.client as mqtt
from paho.mqtt.packettypes import PacketTypes
from paho.mqtt.properties import Properties

DEV = sys.argv[1] if len(sys.argv) > 1 else "dev1"
WILL_DELAY = int(os.environ.get("WILL_DELAY", "0"))      # seconds, MQTT 5
STATUS = f"testmqtt/lwt-test/{DEV}/status"

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
                     client_id=f"testmqtt-lwt-{DEV}", protocol=mqtt.MQTTv5)
# client.username_pw_set("username", "password")

will_props = Properties(PacketTypes.WILLMESSAGE)
will_props.WillDelayInterval = WILL_DELAY
client.will_set(STATUS, "offline", qos=1, retain=True, properties=will_props)

def on_connect(client, userdata, flags, reason_code, properties):
    print(f"connected: {reason_code}, session present: {flags.session_present}", flush=True)
    client.publish(STATUS, "online", qos=1, retain=True)

client.on_connect = on_connect
stop = threading.Event()
signal.signal(signal.SIGTERM, lambda *_: stop.set())

connect_props = Properties(PacketTypes.CONNECT)
connect_props.SessionExpiryInterval = 300                # must be >= will delay
client.connect("broker.connectmq.com", 1883, keepalive=10,
               clean_start=False, properties=connect_props)
client.loop_start()
print("pid", os.getpid(), flush=True)

while not stop.is_set():
    stop.wait(1)

# graceful shutdown: report offline ourselves, then a normal DISCONNECT (no will)
client.publish(STATUS, "offline", qos=1, retain=True).wait_for_publish(5)
client.disconnect()
client.loop_stop()

Em um segundo terminal, inicie um observador que imprime um timestamp a cada mensagem:

Observer
mosquitto_sub -h broker.connectmq.com -p 1883 -t 'testmqtt/lwt-test/#' -F '%I %r %t %p'

%I é um timestamp ISO-8601 e %r a flag retain: o will chega como mensagem ao vivo (0), enquanto um offline antigo de uma execução anterior aparece com 1 logo após a assinatura. Você também pode observar testmqtt/lwt-test/# no cliente MQTT online, o que é prático quando você corta a rede da máquina que roda o dispositivo.

Cenário 1: queda com kill -9

Crash the device
python device.py dev1 &          # prints: pid 41237
kill -9 41237

O observador imprime offline em cerca de um segundo. SIGKILL não pode ser capturado, então o cliente nunca envia DISCONNECT; o kernel fecha a conexão TCP e o broker trata isso como uma desconexão abrupta. Um OOM kill ou um segfault se comportam da mesma forma.

Cenário 2: desligamento ordenado (controle negativo)

Stop the device cleanly
python device.py dev1 & PID=$!
sleep 3; kill -TERM "$PID"

O observador mostra offline exatamente uma vez — publicado pelo próprio dispositivo, não pelo broker. Se aparecer um segundo offline logo depois, o cliente saiu sem um DISCONNECT limpo (por exemplo, o processo terminou antes de o disconnect ser enviado). Acertar esse caso importa tanto quanto o da queda: um will que dispara a cada deploy enche os dashboards de alarmes falsos.

Cenário 3: congelamento e corte de rede

Um processo congelado mantém o socket aberto, então só o keep-alive consegue detectá-lo:

Freeze, then resume
python device.py dev1 & PID=$!
sleep 3; kill -STOP "$PID"   # paho's network thread stops: no PINGREQ
# observer: "offline" after ~15 s (1.5 x keep-alive of 10 s)
kill -CONT "$PID"            # the client wakes up, reconnects and publishes "online"

Para um corte de rede real, descarte o tráfego na máquina do dispositivo. Rode o observador em outro lugar (ou no navegador, pela porta WebSocket segura), porque estas regras também bloqueiam o seu mosquitto_sub local:

Blackhole the broker (Linux, root)
BROKER_IP=$(dig +short broker.connectmq.com | head -1)
sudo iptables -I OUTPUT -p tcp -d "$BROKER_IP" --dport 1883 -j DROP
sudo iptables -I INPUT  -p tcp -s "$BROKER_IP" --sport 1883 -j DROP
# ... wait for the will, then restore:
sudo iptables -D OUTPUT -p tcp -d "$BROKER_IP" --dport 1883 -j DROP
sudo iptables -D INPUT  -p tcp -s "$BROKER_IP" --sport 1883 -j DROP

Em containers, docker pause reproduz o congelamento e docker network disconnect o corte. Para execuções repetíveis no CI, coloque o Toxiproxy entre cliente e broker e use o toxic timeout.

Meça o intervalo entre o último online e o offline na saída do observador. Se for bem maior que 1,5 × o seu keep-alive, o cliente está negociando um keep-alive diferente do que você imagina, ou um proxy ou load balancer no meio mantém a conexão TCP viva do lado do broker.

Cenário 4: will delay do MQTT 5

O Will Delay Interval permite que um dispositivo sobreviva a uma oscilação curta sem se anunciar offline. O broker publica o will quando o atraso expira ou a sessão termina, o que vier primeiro, e o descarta por completo se o cliente reconectar a tempo. Teste os dois resultados:

Will delay: reconnect in time vs. stay away
# A: crash and come back within the delay -> no will
WILL_DELAY=30 python device.py dev2 & PID=$!
sleep 3; kill -9 "$PID"
sleep 10
WILL_DELAY=30 python device.py dev2 & PID=$!      # "session present: True", no offline

# B: crash and stay away -> will about 30 s after the broker notices the loss
sleep 3; kill -9 "$PID"

Se o teste A ainda gerar um offline, verifique três configurações: a reconexão deve usar o mesmo client ID, o Clean Start deve estar desativado (um clean start encerra a sessão antiga, o que dispara o will na hora) e o session expiry deve ser pelo menos igual ao will delay — com o padrão 0 do MQTT 5 a sessão termina na desconexão e o atraso é ignorado. O guia do will delay interval explica essa interação em detalhes.

Automatize o teste de queda

test_lwt.py
import signal, subprocess, threading, time
import paho.mqtt.client as mqtt

TOPIC = "testmqtt/lwt-test/ci/status"

def test_will_is_published_on_crash():
    got = threading.Event()

    def on_message(client, userdata, msg):
        if msg.payload == b"offline" and not msg.retain:   # live will, not stale state
            got.set()

    obs = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
    obs.on_message = on_message
    obs.connect("broker.connectmq.com", 1883)
    obs.subscribe(TOPIC, qos=1)
    obs.loop_start()

    dev = subprocess.Popen(["python", "device.py", "ci"])
    time.sleep(3)                                # connect and publish "online"
    dev.send_signal(signal.SIGKILL)
    t0 = time.monotonic()
    try:
        assert got.wait(10), "Last Will not received within 10 s"
        print(f"will arrived after {time.monotonic() - t0:.1f} s")
    finally:
        obs.publish(TOPIC, b"", qos=1, retain=True).wait_for_publish(5)   # clear status
        obs.disconnect()
        obs.loop_stop()

Adicione o caso ordenado como um segundo teste que envia SIGTERM e verifica que chega exatamente um offline. Se o teste de queda falhar, siga Last Will não enviado: topic do will errado, uma ACL que nega o topic do will ou um broker que o descarta silenciosamente. Combine o will com uma mensagem online retida, como aqui, para construir uma detecção de presença confiável.

Veja mensagens Last Will ao vivo

Um Last Will só está testado quando você o viu disparar em uma queda, em um congelamento e em um corte de rede — e o viu ficar quieto em um desligamento limpo. Registre os tempos junto das configurações de keep-alive e will delay, e você saberá exatamente quanto tempo os seus dashboards levam para perceber que um dispositivo sumiu.

Perguntas frequentes

Por que o meu Last Will do MQTT não é enviado quando paro o cliente?

Porque o cliente parou de forma ordenada. O Last Will só é publicado quando a conexão termina sem um DISCONNECT normal: uma queda, uma falha de rede ou um timeout de keep-alive. Apertar Ctrl+C em um app que chama disconnect() é uma saída limpa, então o broker descarta o will. Use kill -9 ou corte a rede para testar.

Quanto tempo o broker leva para publicar o Last Will?

Se o processo cliente morre, o sistema operacional fecha o socket e o will é publicado quase imediatamente. Se a rede some silenciosamente, o broker espera até passar 1,5 vez o intervalo de keep-alive sem tráfego. No MQTT 5, um Will Delay Interval acrescenta um atraso adicional.

Por que o meu will é publicado mesmo que o cliente tenha reconectado dentro do will delay?

Porque a sessão terminou. No MQTT 5 o will é publicado quando o atraso expira ou quando a sessão termina, o que acontecer primeiro. Se o session expiry interval for 0, ou se o cliente reconectar com Clean Start ativado, a sessão antiga termina e o will é enviado. Reconecte com o mesmo client ID, Clean Start desativado e um session expiry pelo menos igual ao will delay.