From 0472de5a5d4a15664aa192d0febe6e29fc2f846f Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Thu, 18 Jun 2026 17:17:56 +0200 Subject: [PATCH] fix: add SIGKILL fallback when SIGTERM does not stop the alarm --- api/schema.py | 10 ++++++++++ tests/test_api.py | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/api/schema.py b/api/schema.py index e884cd4..c97299d 100644 --- a/api/schema.py +++ b/api/schema.py @@ -2,6 +2,7 @@ import strawberry from typing import List, Optional import os import sys +import time import subprocess import signal from pathlib import Path @@ -138,6 +139,15 @@ class Mutation: with open(PID_FILE) as f: pid = int(f.read().strip()) os.kill(pid, signal.SIGTERM) + # Give the process a short grace period, then escalate to SIGKILL. + for _ in range(20): + time.sleep(0.1) + try: + os.kill(pid, 0) + except ProcessLookupError: + break + else: + os.kill(pid, signal.SIGKILL) if os.path.exists(PID_FILE): os.remove(PID_FILE) return True diff --git a/tests/test_api.py b/tests/test_api.py index 1ba884b..87985b1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,6 +2,7 @@ from unittest.mock import patch, MagicMock from fastapi.testclient import TestClient import tempfile import os +import signal # We need to set the environment variable before importing the app os.environ["API_KEY"] = "test-secret" @@ -295,14 +296,38 @@ def test_start_ringing_ignores_when_already_ringing(): mock_popen.assert_not_called() -def test_stop_ringing_kills_process_when_ringing(): - """stopRinging returns True and kills the process when ringing.""" +def test_stop_ringing_sends_sigterm_when_ringing(): + """stopRinging sends SIGTERM and cleans up the PID file.""" from api.schema import Mutation mutation = Mutation() with patch("api.schema.is_wecker_ringing", return_value=True), \ patch("api.schema.os.kill") as mock_kill, \ + patch("api.schema.os.path.exists", return_value=True), \ + patch("api.schema.os.remove") as mock_remove, \ + patch("common.PID_FILE", "/tmp/fake_wecker.pid"), \ + patch("builtins.open") as mock_open: + mock_f = mock_open.return_value.__enter__.return_value + mock_f.read.return_value = "1234" + mock_kill.side_effect = [None, ProcessLookupError] + result = mutation.stop_ringing() + assert result is True + mock_kill.assert_any_call(1234, signal.SIGTERM) + mock_remove.assert_called_once() + + +def test_stop_ringing_falls_back_to_sigkill(): + """stopRinging escalates to SIGKILL if SIGTERM does not terminate the process.""" + from api.schema import Mutation + + mutation = Mutation() + + with patch("api.schema.is_wecker_ringing", return_value=True), \ + patch("api.schema.os.kill") as mock_kill, \ + patch("api.schema.time.sleep"), \ + patch("api.schema.os.path.exists", return_value=True), \ + patch("api.schema.os.remove"), \ patch("common.PID_FILE", "/tmp/fake_wecker.pid"), \ patch("builtins.open") as mock_open: mock_f = mock_open.return_value.__enter__.return_value @@ -310,7 +335,9 @@ def test_stop_ringing_kills_process_when_ringing(): mock_kill.return_value = None result = mutation.stop_ringing() assert result is True - mock_kill.assert_called_with(1234, 15) + signals_sent = [call_args[0][1] for call_args in mock_kill.call_args_list] + assert signal.SIGTERM in signals_sent + assert signal.SIGKILL in signals_sent def test_stop_ringing_does_nothing_when_not_ringing():