Skip to content

Commit a1f66bc

Browse files
authored
Merge pull request #529 from atsign-foundation/fix/monitor-heartbeat-lifecycle
fix: daemonize the monitor heartbeat thread and scope its locks per instance
2 parents 81bdace + d76ccb0 commit a1f66bc

3 files changed

Lines changed: 93 additions & 45 deletions

File tree

at_client/atclient.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from .connections.atrootconnection import AtRootConnection
1818
from .connections.atsecondaryconnection import AtSecondaryConnection
1919
from .connections.atmonitorconnection import AtMonitorConnection
20-
from .util.atconstants import *
2120
from .connections.address import Address
2221
from .common.keys import AtKey, Keys, SharedKey, PrivateHiddenKey, PublicKey, SelfKey
2322
from .util.authutil import AuthUtil
@@ -375,7 +374,6 @@ def __del__(self):
375374

376375
def start_monitor(self, regex=".*", last_received_time=0):
377376
if self.queue != None:
378-
global should_be_running_lock
379377
what = ""
380378
try:
381379
if self.monitor_connection == None:
@@ -385,13 +383,13 @@ def start_monitor(self, regex=".*", last_received_time=0):
385383
verbose=self.verbose, regex=regex, last_received_time=last_received_time)
386384
self.monitor_connection.connect()
387385
AuthUtil.authenticate_with_pkam(self.monitor_connection, self.atsign, self.keys)
388-
should_be_running_lock.acquire(blocking=1)
386+
self.monitor_connection.should_be_running_lock.acquire(blocking=1)
389387
if not self.monitor_connection.running:
390-
should_be_running_lock.release()
388+
self.monitor_connection.should_be_running_lock.release()
391389
what = "call monitor_connection.start_monitor()"
392390
self.monitor_connection.start_monitor()
393391
else:
394-
should_be_running_lock.release()
392+
self.monitor_connection.should_be_running_lock.release()
395393
except Exception as e:
396394
print("SEVERE: failed to " + what + " : " + str(e))
397395
traceback.print_exc()
@@ -400,18 +398,17 @@ def start_monitor(self, regex=".*", last_received_time=0):
400398

401399
def stop_monitor(self):
402400
if self.queue != None:
403-
global should_be_running_lock
404401
what = ""
405402
try:
406403
if self.monitor_connection == None:
407404
return
408-
should_be_running_lock.acquire(blocking=1)
405+
self.monitor_connection.should_be_running_lock.acquire(blocking=1)
409406
if not self.monitor_connection.running:
410-
should_be_running_lock.release()
407+
self.monitor_connection.should_be_running_lock.release()
411408
what = "call monitor_connection.stop_monitor()"
412409
self.monitor_connection.stop_monitor()
413410
else:
414-
should_be_running_lock.release()
411+
self.monitor_connection.should_be_running_lock.release()
415412
except Exception as e:
416413
print("SEVERE: failed to " + what + " : " + str(e))
417414
traceback.print_exc()

at_client/connections/atmonitorconnection.py

Lines changed: 42 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from .notification.atevents import AtEvent, AtEventType
1010
from ..util.syncdecorator import synchronized
1111
from ..util.timeutil import TimeUtil
12-
from ..util.atconstants import *
1312
from .address import Address
1413
from .atsecondaryconnection import AtSecondaryConnection
1514
import queue
@@ -26,44 +25,54 @@ def __init__(self, queue: queue.Queue, atsign: AtSign, address: Address,
2625
self.regex = regex
2726
self.last_received_time = last_received_time
2827
self._verbose = verbose
28+
self.should_be_running_lock = threading.Lock()
29+
self.running_lock = threading.Lock()
2930
super().__init__(address, context, verbose)
3031
self._last_heartbeat_sent_time = TimeUtil.current_time_millis()
3132
self._last_heartbeat_ack_time = TimeUtil.current_time_millis()
3233
self._heartbeat_interval_millis = 30000
3334
self.start_heart_beat()
34-
35+
3536
def start_heart_beat(self):
36-
threading.Thread(target=self._start_heart_beat).start()
37-
37+
# Daemon: an abandoned connection's heartbeat must not keep the process alive.
38+
# The stop event makes the loop's wait interruptible, so stop_heart_beat()
39+
# takes effect promptly (the Dart SDK's heartbeat is a cancellable Timer).
40+
self._heartbeat_stop_event = threading.Event()
41+
self._heartbeat_thread = threading.Thread(target=self._start_heart_beat, daemon=True)
42+
self._heartbeat_thread.start()
43+
44+
def stop_heart_beat(self):
45+
"""Stop this connection's heartbeat/restart loop."""
46+
self._heartbeat_stop_event.set()
47+
3848
def _start_heart_beat(self):
39-
global should_be_running_lock
40-
while True:
41-
should_be_running_lock.acquire()
49+
while not self._heartbeat_stop_event.is_set():
50+
self.should_be_running_lock.acquire()
4251
if self.should_be_running:
43-
should_be_running_lock.release()
52+
self.should_be_running_lock.release()
4453
if (not self.running) or (self._last_heartbeat_sent_time - self._last_heartbeat_ack_time >= self._heartbeat_interval_millis):
4554
try:
4655
print("Monitor heartbeats not being received")
4756
self.stop_monitor()
4857
wait_start_time = TimeUtil.current_time_millis()
49-
running_lock.acquire(blocking=1)
58+
self.running_lock.acquire(blocking=1)
5059
entered = False
5160
print((TimeUtil.current_time_millis() - wait_start_time) < 5000)
5261
while self.running and ((TimeUtil.current_time_millis() - wait_start_time) < 5000):
5362
entered = True
54-
running_lock.release()
63+
self.running_lock.release()
5564
print("Wait 5 seconds for monitor to stop")
5665
try:
5766
time.sleep(1)
5867
except Exception as ignore:
5968
pass
6069
if not entered:
61-
running_lock.release()
70+
self.running_lock.release()
6271
entered = False
63-
running_lock.acquire(blocking=1)
72+
self.running_lock.acquire(blocking=1)
6473
if self.running:
6574
print("Monitor thread has not stopped, but going to start another one anyway")
66-
running_lock.release()
75+
self.running_lock.release()
6776
self.start_monitor()
6877
except Exception as e:
6978
print("Monitor restart failed "+ str(e))
@@ -76,42 +85,39 @@ def _start_heart_beat(self):
7685
# Can't do anything, the heartbeat loop will take care of restarting the monitor connection
7786
pass
7887
else:
79-
should_be_running_lock.release()
80-
try:
81-
time.sleep(self._heartbeat_interval_millis / 6000) # 6 * 1000 (from ms to s)
82-
except Exception as ignore:
83-
pass
88+
self.should_be_running_lock.release()
89+
self._heartbeat_stop_event.wait(self._heartbeat_interval_millis / 6000) # 6 * 1000 (from ms to s)
8490

8591
def start_monitor(self):
8692
self._last_heartbeat_sent_time = self._last_heartbeat_ack_time = TimeUtil.current_time_millis()
8793

88-
should_be_running_lock.acquire(blocking=1)
94+
self.should_be_running_lock.acquire(blocking=1)
8995
self.should_be_running = True
90-
should_be_running_lock.release()
96+
self.should_be_running_lock.release()
9197

92-
running_lock.acquire(blocking=1)
98+
self.running_lock.acquire(blocking=1)
9399
if not self.running:
94100
self.running = True
95-
running_lock.release()
101+
self.running_lock.release()
96102
if not self._connected:
97103
try:
98104
self._connect()
99105
except Exception as e:
100106
print("startMonitor failed to connect to secondary : " + str(e))
101107
traceback.print_exc()
102-
running_lock.acquire(blocking=1)
108+
self.running_lock.acquire(blocking=1)
103109
self.running = False
104-
running_lock.release()
110+
self.running_lock.release()
105111
return False
106112
self._run()
107113
else:
108-
running_lock.release()
114+
self.running_lock.release()
109115
return True
110116

111117
def stop_monitor(self):
112-
should_be_running_lock.acquire(blocking=1)
118+
self.should_be_running_lock.acquire(blocking=1)
113119
self.should_be_running = False
114-
should_be_running_lock.release()
120+
self.should_be_running_lock.release()
115121

116122
self._last_heartbeat_sent_time = self._last_heartbeat_ack_time = TimeUtil.current_time_millis()
117123
self.disconnect()
@@ -138,9 +144,9 @@ def _run(self):
138144
self.execute_command(command=monitor_cmd, retry_on_exception=True, read_the_response=False)
139145
print("Monitor started on " + str(self.atsign.to_string()))
140146
entered = False
141-
should_be_running_lock.acquire(blocking=1)
147+
self.should_be_running_lock.acquire(blocking=1)
142148
while self.should_be_running:
143-
should_be_running_lock.release()
149+
self.should_be_running_lock.release()
144150
entered = True
145151
first = False
146152
what = "read from connection"
@@ -206,26 +212,26 @@ def _run(self):
206212
at_event = AtEvent(event_type, event_data)
207213
self.queue.put(at_event)
208214

209-
should_be_running_lock.acquire(blocking=1)
215+
self.should_be_running_lock.acquire(blocking=1)
210216
entered = False
211217
if not entered:
212-
should_be_running_lock.release()
218+
self.should_be_running_lock.release()
213219
entered = False
214220
except Exception as e:
215221
traceback.print_exc()
216-
should_be_running_lock.acquire(blocking=1)
222+
self.should_be_running_lock.acquire(blocking=1)
217223
if not self.should_be_running:
218-
should_be_running_lock.release()
224+
self.should_be_running_lock.release()
219225
else:
220-
should_be_running_lock.release()
226+
self.should_be_running_lock.release()
221227
print("Monitor failed to " + what + " : " + str(e))
222228
traceback.print_exc()
223229
print("Monitor ending. Monitor heartbeat thread should restart the monitor shortly")
224230
self.disconnect()
225231
finally:
226-
running_lock.acquire(blocking=1)
232+
self.running_lock.acquire(blocking=1)
227233
self.running = False
228-
running_lock.release()
234+
self.running_lock.release()
229235

230236
self.disconnect()
231237

test/monitor_heartbeat_test.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import unittest
2+
from queue import Queue
3+
4+
from at_client.common import AtSign
5+
from at_client.connections.address import Address
6+
from at_client.connections.atmonitorconnection import AtMonitorConnection
7+
8+
9+
class MonitorHeartbeatTest(unittest.TestCase):
10+
"""Network-free tests for heartbeat thread lifecycle and per-instance locks."""
11+
12+
def _connection(self):
13+
return AtMonitorConnection(queue=Queue(), atsign=AtSign("@alice"),
14+
address=Address("localhost", 64), verbose=False)
15+
16+
def test_heartbeat_thread_is_daemon(self):
17+
"""A non-daemon heartbeat thread would keep the process alive forever."""
18+
conn = self._connection()
19+
self.assertTrue(conn._heartbeat_thread.daemon)
20+
21+
def test_locks_are_per_instance(self):
22+
"""Module-level locks made every monitor in a process share one lock pair."""
23+
a = self._connection()
24+
b = self._connection()
25+
self.assertIsNot(a.should_be_running_lock, b.should_be_running_lock)
26+
self.assertIsNot(a.running_lock, b.running_lock)
27+
28+
def test_one_connections_lock_does_not_block_another(self):
29+
a = self._connection()
30+
b = self._connection()
31+
with a.should_be_running_lock:
32+
self.assertTrue(b.should_be_running_lock.acquire(blocking=False))
33+
b.should_be_running_lock.release()
34+
35+
def test_stop_heart_beat_ends_the_thread(self):
36+
"""The heartbeat loop must exit promptly when stopped, not at process exit."""
37+
conn = self._connection()
38+
self.assertTrue(conn._heartbeat_thread.is_alive())
39+
conn.stop_heart_beat()
40+
conn._heartbeat_thread.join(timeout=2)
41+
self.assertFalse(conn._heartbeat_thread.is_alive())
42+
43+
44+
if __name__ == "__main__":
45+
unittest.main()

0 commit comments

Comments
 (0)