fix: make tracker.stop() idempotent - #1336
davidberenstein1957 wants to merge 2 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #1336 +/- ##
==========================================
+ Coverage 91.70% 91.78% +0.08%
==========================================
Files 49 49
Lines 5157 5162 +5
==========================================
+ Hits 4729 4738 +9
+ Misses 428 424 -4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
045655e to
72b69d6
Compare
stop() used the schedulers as its state flag, so a second call re-ran the final measurement, wrote a second row and released the lock twice -- by then the lock may already belong to another tracker. Add `_stopped_at` as the single state flag: `_start_time is None` means never started, `_stopped_at is not None` means stopped. A second stop() returns the memoised emissions; a start() after stop() is refused with an error instead of half-restarting a tracker whose output handlers, lock and schedulers are already gone. Folds in #1337, which inferred the same state from `self._scheduler`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
72b69d6 to
334f76d
Compare
Handlers were installed before acquire() could fail. On the "another instance is already running" path acquire() raises, the tracker sets _another_instance_already_running and stop() returns at its early guard without ever reaching release() -- so the host application's SIGINT and SIGTERM stayed hijacked for the life of the process. Install them after open(LOCKFILE, "x") succeeds instead, and drop the _atexit_hook indirection: atexit.unregister() compares with ==, not identity, so a bound method unregisters fine. Also make release() idempotent (moved here from #1336): it edits the same few lines of release() this branch already rewrites. The deadlock test now unregisters its atexit hook, so a reverted lock.py fails the suite instead of wedging the interpreter at exit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Lock` installed SIGINT/SIGTERM handlers and threw away the previous ones, so the host application's handlers were destroyed and Ctrl-C stopped raising KeyboardInterrupt. Save the previous handlers, chain to them from `_handle_exit`, and restore them in `release()`, unregistering the atexit hook so a released lock is not pinned. Handlers are installed in `acquire()` after `open(LOCKFILE, "x")` succeeds, not in `__init__`. On the "another instance is already running" path `acquire()` raises, the tracker sets `_another_instance_already_running`, and `stop()` returns at its early guard without ever reaching `release()` -- so handlers installed in the constructor stayed hijacked for the life of the process. The thread lock is reentrant: `_handle_exit` calls `release()`, which takes `_thread_lock`, so a signal delivered while the same thread was inside `acquire()`/`release()` deadlocked on a plain `Lock`. `release()` is also idempotent now (moved here from #1336, since it edits the same few lines this branch already rewrites), and the `_atexit_hook` indirection is dropped: `atexit.unregister()` compares with `==`, not identity, so a bound method unregisters fine. Tests cover the default and ignored signal dispositions, and the deadlock test unregisters its atexit hook so a reverted lock.py fails the suite instead of wedging the interpreter at exit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verdict: 🔧 Request changes (rebase down, or close)Mostly superseded by #1408 (8171d13, now on master). Must fix:
Nit:
|
Merge origin/master and reduce the PR to the part #1408 does not cover: a dedicated `_stopped` flag set in stop() makes start() log an error and return, instead of half-restarting with a None scheduler. Keep the lock-release-once regression test; drop the idempotent-stop test that duplicates #1408. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Made the changes in ee7ff84: merged master, reduced to the restart refusal only.
Refusing vs. properly supporting restart is still your call. |
Description
Calling
tracker.stop()twice wrote a second complete emissions row (two CSV rows, two API POSTs, twohandler.exit()calls) for a single run, because nothing recorded that the tracker had already stopped._initialize_runtime_state()now initializes_is_stopped,final_emissions, andfinal_emissions_data;stop()returns early once_is_stoppedis set, returning the cachedfinal_emissions; the lock release moved after that guard so a repeatstop()no longer retriesos.removeon an already-removed lock file; and the misplacedelse: logger.warning(...)(which was bound to the_scheduler_monitor_powercheck, not a stop-state check) is replaced by the real terminal-state guard.Related Issue
Fixes #1307
Motivation and Context
__exit__callsstop()unconditionally, so awithblock combined with an explicitstop()call (the usual way to get the return value) double-counted the whole run's emissions.How Has This Been Tested?
tests/test_emissions_tracker.py::TestCarbonTracker::test_offline_tracker_stop_is_idempotentasserts one CSV row and an identical return value after a double stop. It fails on master (2 rows) and passes with this change. Full file: 32 passed.Screenshots (if appropriate):
N/A
Types of changes
AI Usage Disclosure
Checklist: