Skip to content

fix: make tracker.stop() idempotent - #1336

Open
davidberenstein1957 wants to merge 2 commits into
masterfrom
fix/stop-idempotent
Open

davidberenstein1957 wants to merge 2 commits into
masterfrom
fix/stop-idempotent

Conversation

@davidberenstein1957

@davidberenstein1957 davidberenstein1957 commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Calling tracker.stop() twice wrote a second complete emissions row (two CSV rows, two API POSTs, two handler.exit() calls) for a single run, because nothing recorded that the tracker had already stopped. _initialize_runtime_state() now initializes _is_stopped, final_emissions, and final_emissions_data; stop() returns early once _is_stopped is set, returning the cached final_emissions; the lock release moved after that guard so a repeat stop() no longer retries os.remove on an already-removed lock file; and the misplaced else: logger.warning(...) (which was bound to the _scheduler_monitor_power check, not a stop-state check) is replaced by the real terminal-state guard.

Related Issue

Fixes #1307

Motivation and Context

__exit__ calls stop() unconditionally, so a with block combined with an explicit stop() 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_idempotent asserts 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

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

AI Usage Disclosure

  • 🟥 AI-vibecoded
  • 🟠 AI-generated
  • ⭐ AI-assisted
  • ♻️ No AI used

Checklist:

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the docs/how-to/contributing.md document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

@codecov

codecov Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.78%. Comparing base (e5e46ab) to head (ee7ff84).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@davidberenstein1957
davidberenstein1957 marked this pull request as ready for review August 12, 2026 19:14
@davidberenstein1957
davidberenstein1957 requested a review from a team as a code owner August 12, 2026 19:14
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>
davidberenstein1957 added a commit that referenced this pull request Aug 19, 2026
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>
davidberenstein1957 added a commit that referenced this pull request Aug 20, 2026
`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>
@benoit-cty

Copy link
Copy Markdown
Contributor

🤖 This review comment was written and posted by Claude Opus 5.5 (AI assistant), at the request of @benoit-cty. Findings were checked by reading the code and running tests locally (merged with current master where relevant), but please double-check before acting on them.

Verdict: 🔧 Request changes (rebase down, or close)

Mostly superseded by #1408 (8171d13, now on master). stop() already sets _start_time = None and returns the cached final_emissions, and issue #1307 is closed. The only new behaviour left here is "refuse to restart after stop".

Must fix:

  1. Conflicts with master, and the remaining logic won't work after a rebase.

    • The _stopped_at guard duplicates fix: make tracker.stop() idempotent #1408, and conflicts with it in stop() (emissions_tracker.py ~L911-925).
    • The restart refusal sits inside if self._start_time is not None: in start(). After fix: make tracker.stop() idempotent #1408 that value is None once stopped, so the refusal would never fire.
    • If you keep this PR, rebase it down to the restart refusal only, and use a dedicated flag (e.g. self._stopped = True set in stop()).
  2. Behaviour choice to confirm with maintainers. On master today, start → stop → start → stop "works", noisily:

    • The second start() logs a suppressed AttributeError: 'NoneType' object has no attribute 'start', because the scheduler is None.
    • The second stop() writes a second CSV row whose energy includes the first run's.

    With this PR, the second run writes nothing and only logs an error. Refusing is defensible, but the error message should say so clearly and suggest creating a new tracker. The alternative is to support restarting properly: re-create the scheduler and reset the counters.

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>
@davidberenstein1957

Copy link
Copy Markdown
Collaborator Author

Made the changes in ee7ff84: merged master, reduced to the restart refusal only.

Refusing vs. properly supporting restart is still your call.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tracker.stop() is not idempotent: a second call writes a duplicate emissions row

2 participants