From b499a9fb777b00dcf97aa341a6d5e4dcf551f357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Mon, 13 Apr 2026 11:33:42 +0200 Subject: [PATCH 1/2] fix: reject heartbeats when lock times out instead of proceeding unsafely When the heartbeat lock couldn't be acquired within 1s, the code would proceed without the lock (causing concurrent SQLite access and "database is locked" errors) then try to release a lock it didn't own. Now returns 503 so the client can retry cleanly. Also increased timeout to 10s to reduce spurious rejections. --- aw_server/rest.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/aw_server/rest.py b/aw_server/rest.py index 8b4b6ff9..a729977b 100644 --- a/aw_server/rest.py +++ b/aw_server/rest.py @@ -291,11 +291,12 @@ def post(self, bucket_id): # This lock is meant to ensure that only one heartbeat is processed at a time, # as the heartbeat function is not thread-safe. # This should maybe be moved into the api.py file instead (but would be very messy). - aquired = self.lock.acquire(timeout=1) - if not aquired: + acquired = self.lock.acquire(timeout=10) + if not acquired: logger.warning( - "Heartbeat lock could not be aquired within a reasonable time, this likely indicates a bug." + "Heartbeat lock could not be acquired within timeout, rejecting request." ) + return {"message": "Server busy, try again later"}, 503 try: event = current_app.api.heartbeat(bucket_id, heartbeat, pulsetime) finally: From 18103b021ff7584e4a62fad5deacb9f3110e0cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Mon, 13 Apr 2026 11:46:45 +0200 Subject: [PATCH 2/2] fix: make heartbeat lock a class variable for actual thread safety The lock was created in __init__ as self.lock = Lock(), but Flask-RESTX instantiates Resource per-request, so each request got its own lock providing zero mutual exclusion. Moving to a class variable ensures all heartbeat requests are properly serialized. --- aw_server/rest.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/aw_server/rest.py b/aw_server/rest.py index a729977b..5a6b8226 100644 --- a/aw_server/rest.py +++ b/aw_server/rest.py @@ -271,8 +271,12 @@ def delete(self, bucket_id: str, event_id: int): @api.route("/0/buckets//heartbeat") class HeartbeatResource(Resource): + # Class-level lock shared across all instances. + # Flask-RESTX creates a new Resource instance per request, so an + # instance-level lock would provide no mutual exclusion. + lock = Lock() + def __init__(self, *args, **kwargs): - self.lock = Lock() super().__init__(*args, **kwargs) @api.expect(event, validate=True)