Skip to content

Fix issue 24#114

Open
rtsdque wants to merge 2 commits into
ActivityWatch:masterfrom
rtsdque:fix-issue-24
Open

Fix issue 24#114
rtsdque wants to merge 2 commits into
ActivityWatch:masterfrom
rtsdque:fix-issue-24

Conversation

@rtsdque

@rtsdque rtsdque commented Jun 19, 2026

Copy link
Copy Markdown

Problem

There's no way to reliably wait for the request queue to finish dispatching all queued heartbeats. The current workaround in the example watcher is a hardcoded sleep(1), which is both fragile and wasteful.

Fix

Added RequestQueue.wait_for_queue_empty(timeout) which polls the queue until it's empty, with an optional timeout in seconds. Also exposed it as ActivityWatchClient.wait_for_queue_empty(timeout) for convenience.

Behavior:

  • Returns True if the queue empties before the timeout
  • Returns False if the timeout is reached first
  • Returns True immediately if the queue thread isn't running

Testing

  • test_wait_for_queue_empty_basic — queue drains normally while running
  • test_wait_for_queue_empty_not_running — returns True immediately if thread not started
  • test_wait_for_queue_empty_timeout — returns False when queue can't drain in time
  • All 6 tests passing, mypy clean, no new ruff issues introduced

Fixes #24

rtsdque added 2 commits June 16, 2026 16:38
Prevents a crash when the disk is full by catching OSError in add_request() and logging a warning instead of letting it propagate.

Fixes ActivityWatch#8
Adds RequestQueue.wait_for_queue_empty() which blocks until all queued heartbeats have been dispatched, with an optional timeout. Also exposes it via ActivityWatchClient.wait_for_queue_empty() for convenience. Replaces the need for callers to use a hardcoded sleep() to wait for the queue to drain.

Fixes ActivityWatch#24
@greptile-apps

greptile-apps Bot commented Jun 19, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds RequestQueue.wait_for_queue_empty(timeout) (and a thin wrapper on ActivityWatchClient) so callers can reliably block until all queued heartbeats have been dispatched, replacing the fragile sleep(1) workaround. It also hardens add_request against disk-full OSErrors and adds four new tests.

  • wait_for_queue_empty polls qsize() and _current every 100 ms and uses the existing _stop_event to wake early; the is_alive() guard at entry handles the "thread never started" case.
  • add_request now catches OSError and logs a warning instead of propagating the exception to the caller when the underlying SQLite queue can't be written.
  • The new timeout test mocks _post with a 10-second sleep, which causes rq.join() to block for the full sleep duration and noticeably slows the test suite.

Confidence Score: 3/5

The core polling loop needs a guard for unexpected thread death before it is safe to call without a timeout.

The wait_for_queue_empty loop checks is_alive() once at entry but never again during polling. If the RequestQueue thread terminates without setting its stop event, any no-timeout call will spin forever burning CPU with no way to break out. This is a present defect on the new code path that should be addressed before merging.

aw_client/client.py — specifically the wait_for_queue_empty polling loop in RequestQueue.

Important Files Changed

Filename Overview
aw_client/client.py Adds wait_for_queue_empty to both ActivityWatchClient and RequestQueue, and wraps add_request's persistqueue.put in an OSError handler. The polling loop in RequestQueue.wait_for_queue_empty only checks is_alive() at entry — if the thread dies mid-wait with no timeout set, the loop spins indefinitely.
tests/test_requestqueue.py Adds four new tests covering disk-full handling and wait_for_queue_empty scenarios. The timeout test will block rq.join() for ~10 s due to an unconditional 10-second sleep in the mock.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant ActivityWatchClient
    participant RequestQueue
    participant PersistQueue

    Caller->>ActivityWatchClient: wait_for_queue_empty(timeout)
    ActivityWatchClient->>RequestQueue: wait_for_queue_empty(timeout)

    RequestQueue->>RequestQueue: is_alive()?
    alt thread not running
        RequestQueue-->>Caller: True (immediately)
    else thread alive
        loop poll every 100 ms
            RequestQueue->>PersistQueue: qsize()
            RequestQueue->>RequestQueue: _current is not None?
            alt queue empty and no current item
                RequestQueue-->>Caller: True
            else stop() called (stop_event set)
                RequestQueue-->>Caller: False
            else timeout exceeded
                RequestQueue-->>Caller: False
            else thread dies unexpectedly (no stop_event)
                Note over RequestQueue: loop spins indefinitely if timeout=None
            end
        end
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant ActivityWatchClient
    participant RequestQueue
    participant PersistQueue

    Caller->>ActivityWatchClient: wait_for_queue_empty(timeout)
    ActivityWatchClient->>RequestQueue: wait_for_queue_empty(timeout)

    RequestQueue->>RequestQueue: is_alive()?
    alt thread not running
        RequestQueue-->>Caller: True (immediately)
    else thread alive
        loop poll every 100 ms
            RequestQueue->>PersistQueue: qsize()
            RequestQueue->>RequestQueue: _current is not None?
            alt queue empty and no current item
                RequestQueue-->>Caller: True
            else stop() called (stop_event set)
                RequestQueue-->>Caller: False
            else timeout exceeded
                RequestQueue-->>Caller: False
            else thread dies unexpectedly (no stop_event)
                Note over RequestQueue: loop spins indefinitely if timeout=None
            end
        end
    end
Loading

Reviews (1): Last reviewed commit: "Implement wait_for_queue_empty method" | Re-trigger Greptile

Comment thread aw_client/client.py
Comment on lines +500 to +506
while self._persistqueue.qsize() > 0 or self._current is not None:
if timeout is not None and (datetime.now() - start_time).total_seconds() >= timeout:
return False
if self.wait(0.1):
# stop() was called while waiting
return False
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Thread death leaves wait_for_queue_empty spinning indefinitely

is_alive() is checked once at entry, but not inside the polling loop. If the RequestQueue thread dies unexpectedly (e.g., an unhandled exception propagates out of run()'s inner loops without setting _stop_event), self.wait(0.1) will keep returning False on timeout every 100 ms, qsize() and _current will never change, and any call with timeout=None will spin forever. Adding if not self.is_alive(): return False as the first check inside the loop would break out cleanly in that scenario.

Comment on lines +112 to +117
with mock.patch.object(client, "_post", slow_post):
rq.start()
rq.add_request("/api/0/buckets/test/heartbeat", {})
result = rq.wait_for_queue_empty(timeout=0.5)
rq.stop()
rq.join()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 rq.join() blocks for ~10 seconds in the timeout test

slow_post sleeps for 10 seconds unconditionally. After wait_for_queue_empty(timeout=0.5) returns, rq.stop() is called but the queue thread is already inside slow_post with no way to interrupt it. rq.join() then has to wait the remaining ~9.5 seconds before the thread exits. The test effectively adds ~10 s to the suite on every run. Using a much shorter sleep (e.g. 0.5 s) or a threading.Event that can be signaled by stop() would keep the test fast while still exercising the timeout path reliably.

rq.stop()
rq.join()

assert result is False No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing newline at end of file. Both changed files (client.py and test_requestqueue.py) are missing a trailing newline, which can cause noisy diffs and violates POSIX text file conventions.

Suggested change
assert result is False
assert result is False

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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.

Implement some "wait for queue to empty" call

1 participant