Para probar un Last Will de MQTT tienes que hacer que el cliente muera mal. El broker publica el will solo cuando una conexión termina sin un DISCONNECT correcto, así que detener tu app con normalidad no demuestra nada. Mata el proceso con kill -9, congélalo con kill -STOP o corta la red; después observa el topic del will desde un segundo cliente y anota cuándo llega. Una buena prueba incluye también el caso negativo: un apagado ordenado no debe disparar el will.
Esta guía te da un pequeño script de dispositivo con will, un observador, las inyecciones de fallos que activan cada camino de código y pruebas del will delay de MQTT 5.
Cuándo publica el broker el will
| Evento | ¿Se publica el will? | Tiempo habitual |
|---|---|---|
Caída del proceso (kill -9, segfault, OOM kill) | Sí | Casi inmediato: el sistema operativo cierra el socket |
| Proceso congelado, socket aún abierto | Sí | Tras 1,5 × keep-alive sin tráfico |
| Corte de red, apagón, cable desconectado | Sí | Tras 1,5 × keep-alive (no llega FIN ni RST) |
| Error de protocolo, el broker cierra la conexión | Sí | Inmediato |
DISCONNECT ordenado (reason code 0) | No | — |
DISCONNECT de MQTT 5 con reason 0x04 | Sí | Inmediato (más el will delay) |
| MQTT 5 con Will Delay Interval | Sí, salvo que el cliente se reconecte a tiempo | Al vencer el retraso o al terminar la sesión, lo que ocurra primero |
La fila del keep-alive explica por qué "mi will tarda una eternidad" es tan común: con un keep-alive de 60 segundos, un dispositivo celular muerto se notifica tras unos 90 segundos. Es el comportamiento correcto, no un bug; consulta keep alive para elegir el intervalo. El concepto en sí se explica en Last Will de MQTT.
El dispositivo bajo prueba
Este script de paho-mqtt 2.x se comporta como un dispositivo bien diseñado: registra un will retenido offline, publica un online retenido al conectar y, con SIGTERM, se apaga ordenadamente (publica offline por sí mismo y se desconecta limpiamente). Así tienes un único proceso con un camino de salida limpio y varios sucios.
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()En una segunda terminal, arranca un observador que imprima una marca de tiempo con cada mensaje:
mosquitto_sub -h broker.connectmq.com -p 1883 -t 'testmqtt/lwt-test/#' -F '%I %r %t %p'%I es una marca de tiempo ISO-8601 y %r el flag retain: el will llega como mensaje en vivo (0), mientras que un offline antiguo de una ejecución anterior aparece con 1 justo después de suscribirte. También puedes observar testmqtt/lwt-test/# en el cliente MQTT online, algo práctico cuando cortas la red de la máquina donde corre el dispositivo.
Escenario 1: caída con kill -9
python device.py dev1 & # prints: pid 41237
kill -9 41237El observador imprime offline en aproximadamente un segundo. SIGKILL no se puede capturar, así que el cliente nunca envía DISCONNECT; el kernel cierra la conexión TCP y el broker lo trata como una desconexión abrupta. Una muerte por falta de memoria o un segfault se comportan igual.
Escenario 2: apagado ordenado (control negativo)
python device.py dev1 & PID=$!
sleep 3; kill -TERM "$PID"El observador muestra offline exactamente una vez, publicado por el propio dispositivo y no por el broker. Si ves un segundo offline poco después, el cliente salió sin un DISCONNECT limpio (por ejemplo, el proceso terminó antes de enviar el disconnect). Acertar en este caso importa tanto como en el de la caída: un will que se dispara en cada despliegue inunda los dashboards de falsas alarmas.
Escenario 3: congelación y corte de red
Un proceso congelado mantiene su socket abierto, así que solo el keep-alive puede detectarlo:
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 un corte de red real, descarta el tráfico en la máquina del dispositivo. Ejecuta el observador en otro sitio (o en el navegador por el puerto WebSocket seguro), porque estas reglas también bloquean tu mosquitto_sub local:
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 DROPEn contenedores, docker pause reproduce la congelación y docker network disconnect el corte. Para ejecuciones repetibles en CI, coloca Toxiproxy entre cliente y broker y usa su toxic timeout.
Mide el intervalo entre el último online y el offline en la salida del observador. Si es mucho mayor que 1,5 × tu keep-alive, el cliente está negociando un keep-alive distinto del que crees, o un proxy o balanceador intermedio mantiene viva la conexión TCP en el lado del broker.
Escenario 4: will delay de MQTT 5
El Will Delay Interval permite que un dispositivo sobreviva a un microcorte sin anunciarse como offline. El broker publica el will cuando vence el retraso o termina la sesión, lo que ocurra primero, y lo omite por completo si el cliente se reconecta a tiempo. Prueba ambos resultados:
# 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"Si la prueba A sigue produciendo un offline, revisa tres ajustes: la reconexión debe usar el mismo client ID, Clean Start debe estar desactivado (un clean start termina la sesión antigua, lo que dispara el will al instante) y el session expiry debe ser al menos igual al will delay; con el valor por defecto de MQTT 5, 0, la sesión termina al desconectar y el retraso se ignora. La guía del will delay interval explica la interacción en detalle.
Automatiza la prueba de caída
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()Añade el caso ordenado como segunda prueba que envía SIGTERM y comprueba que llega exactamente un offline. Si la prueba de caída falla, revisa Last Will no se envía: topic del will incorrecto, una ACL que deniega el topic del will o un broker que lo descarta en silencio. Combina el will con un mensaje online retenido, como aquí, para construir una detección de presencia fiable.
Observa mensajes Last Will en vivo
Un Last Will solo está probado cuando lo has visto dispararse ante una caída, una congelación y un corte de red, y lo has visto quedarse callado ante un apagado limpio. Anota los tiempos junto a tu keep-alive y tu will delay, y sabrás exactamente cuánto tardan tus dashboards en notar que un dispositivo ha desaparecido.
Preguntas frecuentes
¿Por qué no se envía mi Last Will de MQTT cuando detengo el cliente?
Porque el cliente se detuvo de forma ordenada. El Last Will solo se publica cuando la conexión termina sin un DISCONNECT normal: una caída, un fallo de red o un timeout de keep-alive. Pulsar Ctrl+C en una app que llama a disconnect() es una salida limpia, así que el broker descarta el will. Usa kill -9 o corta la red para probarlo.
¿Cuánto tarda el broker en publicar el Last Will?
Si el proceso cliente muere, el sistema operativo cierra el socket y el will se publica casi de inmediato. Si la red desaparece en silencio, el broker espera hasta que pasan 1,5 veces el intervalo de keep-alive sin tráfico. En MQTT 5, un Will Delay Interval añade un retraso adicional.
¿Por qué se publica mi will aunque el cliente se reconectó dentro del will delay?
Porque la sesión terminó. En MQTT 5 el will se publica cuando vence el retraso o cuando termina la sesión, lo que ocurra primero. Si el session expiry interval es 0, o el cliente se reconecta con Clean Start activado, la sesión antigua termina y el will se envía. Reconecta con el mismo client ID, Clean Start desactivado y un session expiry al menos igual al will delay.

