fix: refuse restart after stop() - #1336
davidberenstein1957 wants to merge 3 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 5160 +3
==========================================
+ Hits 4729 4736 +7
+ 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
Stop idempotency itself (a repeat
stop()writing only one CSV row) is covered by the already-merged #1408. This PR adds a_stoppedguard, set bystop(), and checked by bothstart()andstart_task(), so calling either afterstop()logs the same clear "already stopped, cannot be restarted" error instead of running into whatever the now-torn-down state produces. On master,start_task()afterstop()hitsself._scheduler_monitor_power.start()onNone, since_schedulerisNonepost-stop, and raisesAttributeError;start()afterstop()was not previously guarded either._stoppedis initialized in_initialize_runtime_state()and only ever set instop().Also adds a regression test,
test_stop_releases_the_lock_only_once, covering that a repeatstop()does not release the lock a second time (the lock by then may belong to a different tracker instance).Related Issue
None. Stop idempotency itself (one CSV row on a double
stop()) is covered by the already-merged #1408.Motivation and Context
start()andstart_task()are two separate entry points that a caller can invoke afterstop(). Before this PR, neither refused to run after a stop, so a caller could hit stale or torn-down state; in the case ofstart_task(), that meant an unrelatedAttributeErrorfrom calling.start()on the now-None_scheduler_monitor_power, instead of a clear error saying the tracker cannot be restarted.How Has This Been Tested?
tests/test_emissions_tracker.py::TestRestartAfterStop::test_start_after_stop_is_refusedcallsstart()afterstop()and asserts the "cannot be restarted" error is logged.tests/test_emissions_tracker.py::TestRestartAfterStop::test_start_task_after_stop_is_refusedcallsstart_task()afterstop()and asserts the same error is logged, with no active task started, instead of anAttributeError. It fails on master (AttributeError: 'NoneType' object has no attribute 'start') and passes with this change.tests/test_emissions_tracker.py::TestCarbonTracker::test_stop_releases_the_lock_only_onceassertsLock.release()is called exactly once across twostop()calls.Full file: 38 passed (
tests/test_emissions_tracker.py+tests/test_offline_emissions_tracker.py).Screenshots (if appropriate):
N/A
Types of changes
AI Usage Disclosure
Checklist: