Pour tester un Last Will MQTT, il faut faire mourir le client salement. Le broker ne publie le will que lorsqu'une connexion se termine sans DISCONNECT en bonne et due forme — arrêter normalement votre application ne prouve donc rien. Tuez le processus avec kill -9, gelez-le avec kill -STOP ou coupez le réseau, puis observez le topic du will depuis un second client et notez quand il arrive. Un bon test inclut aussi le cas négatif : un arrêt propre ne doit pas déclencher le will.
Ce guide fournit un petit script d'appareil avec will, un observateur, les injections de pannes qui déclenchent chaque chemin de code et des tests du will delay de MQTT 5.
Quand le broker publie le will
| Événement | Will publié ? | Délai typique |
|---|---|---|
Crash du processus (kill -9, segfault, OOM kill) | Oui | Presque immédiat — le système ferme le socket |
| Processus gelé, socket toujours ouvert | Oui | Après 1,5 × keep-alive sans trafic |
| Coupure réseau, panne de courant, câble débranché | Oui | Après 1,5 × keep-alive (aucun FIN ni RST n'arrive) |
| Erreur de protocole, le broker ferme la connexion | Oui | Immédiat |
DISCONNECT propre (reason code 0) | Non | — |
DISCONNECT MQTT 5 avec reason 0x04 | Oui | Immédiat (plus le will delay) |
| MQTT 5 avec Will Delay Interval | Oui, sauf si le client se reconnecte à temps | Expiration du délai ou fin de session, selon ce qui arrive en premier |
La ligne keep-alive explique pourquoi « mon will met une éternité » est si fréquent : avec un keep-alive de 60 secondes, un appareil cellulaire mort est signalé au bout d'environ 90 secondes. C'est le comportement correct, pas un bug — voir keep alive pour choisir l'intervalle. Le concept lui-même est présenté dans Last Will MQTT.
L'appareil testé
Ce script paho-mqtt 2.x se comporte comme un appareil bien conçu : il enregistre un will retenu offline, publie un online retenu à la connexion et, sur SIGTERM, s'arrête proprement (il publie lui-même offline et se déconnecte correctement). Vous obtenez ainsi un seul processus avec un chemin de sortie propre et plusieurs chemins sales.
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()Dans un second terminal, lancez un observateur qui affiche un horodatage avec chaque message :
mosquitto_sub -h broker.connectmq.com -p 1883 -t 'testmqtt/lwt-test/#' -F '%I %r %t %p'%I est un horodatage ISO-8601 et %r le flag retain : le will arrive comme message en direct (0), tandis qu'un ancien offline d'une exécution précédente apparaît avec 1 juste après l'abonnement. Vous pouvez aussi surveiller testmqtt/lwt-test/# dans le client MQTT en ligne, pratique quand vous coupez le réseau de la machine qui exécute l'appareil.
Scénario 1 : crash avec kill -9
python device.py dev1 & # prints: pid 41237
kill -9 41237L'observateur affiche offline en une seconde environ. SIGKILL ne peut pas être intercepté, le client n'envoie donc jamais de DISCONNECT ; le noyau ferme la connexion TCP et le broker la traite comme une déconnexion brutale. Un OOM kill ou un segfault se comportent de la même façon.
Scénario 2 : arrêt propre (contrôle négatif)
python device.py dev1 & PID=$!
sleep 3; kill -TERM "$PID"L'observateur affiche offline exactement une fois — publié par l'appareil lui-même, pas par le broker. Si un second offline apparaît peu après, le client est sorti sans DISCONNECT propre (par exemple, le processus s'est terminé avant l'envoi du disconnect). Réussir ce cas compte autant que le crash : un will qui se déclenche à chaque déploiement inonde les dashboards de fausses alertes.
Scénario 3 : gel et coupure réseau
Un processus gelé garde son socket ouvert, seul le keep-alive peut donc le détecter :
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"Pour une vraie coupure réseau, bloquez le trafic sur la machine de l'appareil. Exécutez l'observateur ailleurs (ou dans le navigateur via le port WebSocket sécurisé), car ces règles bloquent aussi votre 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 DROPAvec des conteneurs, docker pause reproduit le gel et docker network disconnect la coupure. Pour des exécutions reproductibles en CI, placez Toxiproxy entre le client et le broker et utilisez son toxic timeout.
Mesurez l'écart entre le dernier online et le offline dans la sortie de l'observateur. S'il est nettement supérieur à 1,5 × votre keep-alive, le client négocie un keep-alive différent de celui que vous pensez, ou un proxy ou load balancer intermédiaire maintient la connexion TCP en vie côté broker.
Scénario 4 : will delay MQTT 5
Le Will Delay Interval permet à un appareil de survivre à une brève coupure sans s'annoncer hors ligne. Le broker publie le will quand le délai expire ou quand la session se termine, selon ce qui arrive en premier, et l'annule complètement si le client se reconnecte à temps. Testez les deux issues :
# 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 le test A produit encore un offline, vérifiez trois réglages : la reconnexion doit utiliser le même client ID, Clean Start doit être désactivé (un clean start termine l'ancienne session, ce qui déclenche le will aussitôt) et le session expiry doit être au moins égal au will delay — avec la valeur par défaut de MQTT 5, 0, la session se termine à la déconnexion et le délai est ignoré. Le guide du will delay interval détaille cette interaction.
Automatiser le test de crash
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()Ajoutez le cas propre comme second test qui envoie SIGTERM et vérifie qu'un seul offline arrive. Si le test de crash échoue, parcourez Last Will non envoyé : mauvais topic de will, ACL qui refuse le topic du will, ou broker qui l'abandonne silencieusement. Associez le will à un message online retenu, comme ici, pour construire une détection de présence fiable.
Observez les messages Last Will en direct
Un Last Will n'est testé qu'une fois que vous l'avez vu se déclencher lors d'un crash, d'un gel et d'une coupure réseau — et rester silencieux lors d'un arrêt propre. Notez les délais à côté de vos réglages de keep-alive et de will delay, et vous saurez exactement combien de temps vos dashboards mettent à remarquer qu'un appareil a disparu.
Questions fréquentes
Pourquoi mon Last Will MQTT n'est-il pas envoyé quand j'arrête le client ?
Parce que le client s'est arrêté proprement. Un Last Will n'est publié que si la connexion se termine sans DISCONNECT normal : crash, panne réseau ou timeout de keep-alive. Un Ctrl+C dans une application qui appelle disconnect() est une sortie propre, le broker jette donc le will. Utilisez kill -9 ou coupez le réseau pour le tester.
Combien de temps le broker met-il à publier le Last Will ?
Si le processus client meurt, le système d'exploitation ferme le socket et le will est publié presque immédiatement. Si le réseau disparaît silencieusement, le broker attend que 1,5 fois l'intervalle de keep-alive s'écoule sans trafic. En MQTT 5, un Will Delay Interval ajoute un délai supplémentaire.
Pourquoi mon will est-il publié alors que le client s'est reconnecté pendant le will delay ?
Parce que la session s'est terminée. En MQTT 5, le will est publié quand le délai expire ou quand la session se termine, selon ce qui arrive en premier. Si le session expiry interval vaut 0, ou si le client se reconnecte avec Clean Start activé, l'ancienne session se termine et le will part. Reconnectez-vous avec le même client ID, Clean Start désactivé et un session expiry au moins égal au will delay.

