fix(issue-5): scope the persistent publisher's CONNACK result to its own connection
Found while self-reviewing phase 1. PersistentPublisher held the connect
handshake result on the instance, so a refused attempt left its reason code
behind for the next one. On retry the wait loop saw a non-None value straight
away and never waited for the new connection's CONNACK, which is wrong in both
directions:
* a healthy second connection was rejected using the first attempt's rc, and
* once a late callback had written a success rc, a subsequent connection that
never acknowledged could be accepted on that stale value, letting a publish
proceed on a socket that was not ready.
Both need an asynchronous CONNACK to reproduce, which is exactly how paho
delivers it; the earlier fake called the callback inline and hid it. The
handshake state is now a local the callback closes over, so it can only ever
describe the connection that produced it, and a client that fails the handshake
is torn down instead of leaked.
No production path is affected today: the CLI and shell helpers publish once
per process and use OneShotPublisher. The defect mattered because the phase 2a
engine is meant to build on this prototype, which is where a transient refusal
would have become a stuck publisher.
Also tightens PV-1 to check the bound tree against pytest's real basetemp
rather than sniffing for "pytest-" in the path.
Verified: the three new regressions fail against the previous implementation
(2 of 3, the third covers the healthy path) and pass against this one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
This commit is contained in:
1 parent
508ca99d71
commit
f5286a0e67
2 files changed
+151
-20
No files matched your search
@@ -100,7 +100,6 @@ class PersistentPublisher(_BasePublisher):
|
||||
self.connect_count = 0
|
||||
self._client = None
|
||||
self._config = None
|
||||
self._connected = {"rc": None}
|
||||
|
||||
def __enter__(self):
|
||||
return self.open()
|
||||
@@ -124,8 +123,14 @@ class PersistentPublisher(_BasePublisher):
|
||||
client = self.client_factory("publisher", config)
|
||||
self.connect_count += 1
|
||||
|
||||
# The handshake result belongs to THIS client, so it lives in a local the
|
||||
# callback closes over. Holding it on self would let a refused attempt's
|
||||
# reason code be read by the next attempt: the retry would either reject a
|
||||
# healthy connection or, worse, accept one whose CONNACK never arrived.
|
||||
connected = {"rc": None}
|
||||
|
||||
def on_connect(_c, _u, _flags, reason_code, _props):
|
||||
self._connected["rc"] = reason_code
|
||||
connected["rc"] = reason_code
|
||||
|
||||
client.on_connect = on_connect
|
||||
client.connect(config.host, config.port, config.keepalive)
|
||||
@@ -133,19 +138,29 @@ class PersistentPublisher(_BasePublisher):
|
||||
reconnect_delay_set = getattr(client, "reconnect_delay_set", None)
|
||||
if reconnect_delay_set is not None:
|
||||
reconnect_delay_set(min_delay=1, max_delay=16)
|
||||
deadline = time.monotonic() + CONNECT_ACK_TIMEOUT
|
||||
while self._connected["rc"] is None and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
if self._connected["rc"] is None:
|
||||
client.loop_stop()
|
||||
raise TimeoutError("no CONNACK from broker")
|
||||
if mqtt_common.reason_code_value(self._connected["rc"]) != 0:
|
||||
client.loop_stop()
|
||||
raise ConnectionError("broker refused connection: rc={}".format(self._connected["rc"]))
|
||||
try:
|
||||
deadline = time.monotonic() + CONNECT_ACK_TIMEOUT
|
||||
while connected["rc"] is None and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
if connected["rc"] is None:
|
||||
raise TimeoutError("no CONNACK from broker")
|
||||
if mqtt_common.reason_code_value(connected["rc"]) != 0:
|
||||
raise ConnectionError("broker refused connection: rc={}".format(connected["rc"]))
|
||||
except Exception:
|
||||
self._discard(client) # never leak the half-open client on the way out
|
||||
raise
|
||||
self._client = client
|
||||
self._config = config
|
||||
return client
|
||||
|
||||
@staticmethod
|
||||
def _discard(client):
|
||||
try:
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
except Exception: # pragma: no cover - best effort
|
||||
pass
|
||||
|
||||
def publish(self, prepared):
|
||||
client = self._ensure_client(prepared.config)
|
||||
info = client.publish(prepared.topic, payload=prepared.body,
|
||||
@@ -159,11 +174,7 @@ class PersistentPublisher(_BasePublisher):
|
||||
if self._client is None:
|
||||
return
|
||||
try:
|
||||
self._client.loop_stop()
|
||||
self._client.disconnect()
|
||||
except Exception: # pragma: no cover - best effort
|
||||
pass
|
||||
self._discard(self._client)
|
||||
finally:
|
||||
self._client = None
|
||||
self._config = None
|
||||
self._connected = {"rc": None}
|
||||
Reference in new issue
Block a user