diff --git a/app-instance/backend/beaver/services/cron_service.py b/app-instance/backend/beaver/services/cron_service.py index 21678a9..9072ec6 100644 --- a/app-instance/backend/beaver/services/cron_service.py +++ b/app-instance/backend/beaver/services/cron_service.py @@ -134,6 +134,7 @@ class CronService: return job def update_enabled(self, job_id: str, enabled: bool) -> CronJob | None: + updated_job: CronJob | None = None with self._lock: jobs = self._load_jobs_unlocked() for job in jobs: @@ -143,9 +144,11 @@ class CronService: job.updated_at_ms = _now_ms() job.next_run_at_ms = compute_next_run(job.schedule) if job.enabled else None self._save_jobs_unlocked() - self._arm_timer() - return job - return None + updated_job = job + break + if updated_job is not None: + self._arm_timer() + return updated_job def remove_job(self, job_id: str) -> bool: with self._lock: diff --git a/app-instance/backend/tests/unit/test_cron_service.py b/app-instance/backend/tests/unit/test_cron_service.py index 3bbe880..b6476a0 100644 --- a/app-instance/backend/tests/unit/test_cron_service.py +++ b/app-instance/backend/tests/unit/test_cron_service.py @@ -1,4 +1,5 @@ import asyncio +import threading from beaver.foundation.models import CronExecutionResult, CronRunRecord, CronSchedule from beaver.tools.base import ToolContext @@ -108,6 +109,31 @@ def test_persisted_interval_job_keeps_schedule_and_next_run(tmp_path) -> None: assert reloaded.next_run_at_ms == job.next_run_at_ms +def test_running_scheduler_can_disable_job_without_deadlock(tmp_path) -> None: + service = CronService(tmp_path / "jobs.json") + job = service.add_job( + name="Hydration reminder", + message="Drink water", + schedule=CronSchedule(kind="every", every_ms=30 * 60 * 1000), + ) + service._running = True + completed = threading.Event() + enabled_values: list[bool] = [] + + def disable_job() -> None: + updated = service.update_enabled(job.id, False) + if updated is not None: + enabled_values.append(updated.enabled) + completed.set() + + worker = threading.Thread(target=disable_job, daemon=True) + worker.start() + + assert completed.wait(0.5), "disabling a running cron job should not deadlock" + assert enabled_values == [False] + assert service.get_job(job.id).enabled is False + + def test_cron_tool_uses_runtime_service(tmp_path) -> None: service = CronService(tmp_path / "jobs.json") tool = CronTool()