Skip to content

fix: daemonize the monitor heartbeat thread and scope its locks per instance#529

Merged
cconstab merged 3 commits into
trunkfrom
fix/monitor-heartbeat-lifecycle
Jul 8, 2026
Merged

fix: daemonize the monitor heartbeat thread and scope its locks per instance#529
cconstab merged 3 commits into
trunkfrom
fix/monitor-heartbeat-lifecycle

Conversation

@cconstab

@cconstab cconstab commented Jul 7, 2026

Copy link
Copy Markdown
Member

Two lifecycle defects in AtMonitorConnection:

  1. The heartbeat thread was non-daemon, ran an infinite loop, and had no stop mechanism. Any process that constructed a monitor connection could never exit cleanly, and every abandoned/replaced connection leaked a live thread.
  2. should_be_running_lock / running_lock were module-level singletons (defined in atconstants, star-imported here and used from AtClient), so every monitor connection in a process shared one lock pair and serialized against the others' state transitions.

Changes

  • The heartbeat thread is created with daemon=True and kept on the instance as _heartbeat_thread.
  • The loop waits on a threading.Event instead of time.sleep, and a new stop_heart_beat() sets it — so the thread ends promptly when a connection is retired, not only at process exit.
  • The two locks are now instance attributes on AtMonitorConnection; AtClient.start_monitor/stop_monitor use the connection's own locks.
  • The now-unused atconstants star imports are removed from both files. The lock definitions in atconstants are deliberately left in place so any external importer keeps working.

Reference Dart SDK parity: the Dart at_client monitor's heartbeat is a cancellable Timer (heartbeatTimer / stopHeartbeat() in packages/at_client/lib/src/manager/monitor.dart), stopped when the connection closes, with all state per-instance. The Event-based stoppable loop is the Python equivalent.

Tests (test/monitor_heartbeat_test.py, network-free): heartbeat thread is a daemon; locks are per-instance; one connection's held lock does not block another's; stop_heart_beat() ends the thread within a 2s join. Note the test suite itself would hang at interpreter exit without the daemon fix.

No behaviour change to the monitor protocol or reconnect logic.

…nstance

Two defects in AtMonitorConnection's lifecycle:

1. The heartbeat thread was non-daemon and runs an infinite loop, so any process
   that ever constructed a monitor connection could not exit, and every
   abandoned/replaced connection leaked a live thread.

2. should_be_running_lock and running_lock were module-level singletons (in
   atconstants, star-imported here and used from AtClient), so every monitor
   connection in a process shared one lock pair and serialized against the
   others' state transitions.

The heartbeat thread is now created with daemon=True and kept on the instance
(_heartbeat_thread). The locks are now instance attributes; AtClient uses the
connection's own locks. The now-unused atconstants star imports are removed from
both files; the definitions in atconstants are left in place so any external
importer keeps working.

Adds network-free tests: daemon flag, per-instance lock identity, and that one
connection's lock does not block another's.
Aligns with the reference Dart SDK, whose heartbeat is a cancellable Timer
(stopHeartbeat() in monitor.dart): the loop now waits on a threading.Event
instead of time.sleep, and stop_heart_beat() sets it, so the thread exits
promptly rather than only at process exit. Adds a test that the thread ends
within the join timeout after stop_heart_beat().
@cconstab cconstab merged commit a1f66bc into trunk Jul 8, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants