From 7255722b67fd4452f576dba1fc19c39c99e068c8 Mon Sep 17 00:00:00 2001 From: misyaguziya <53165965+misyaguziya@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:01:31 +0900 Subject: [PATCH] =?UTF-8?q?=E3=82=A6=E3=82=A9=E3=83=83=E3=83=81=E3=83=89?= =?UTF-8?q?=E3=83=83=E3=82=B0=E3=81=AE=E3=83=89=E3=82=AD=E3=83=A5=E3=83=A1?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=82=92=E6=9B=B4=E6=96=B0=E3=81=97=E3=80=81?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E4=BE=8B=E3=82=92=E8=BF=BD=E5=8A=A0=E3=80=82?= =?UTF-8?q?=E5=9E=8B=E6=B3=A8=E9=87=88=E3=81=A8=E3=82=A8=E3=83=A9=E3=83=BC?= =?UTF-8?q?=E3=83=8F=E3=83=B3=E3=83=89=E3=83=AA=E3=83=B3=E3=82=B0=E3=81=AE?= =?UTF-8?q?=E6=94=B9=E5=96=84=E3=82=92=E5=8F=8D=E6=98=A0=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-python/docs/modules/watchdog.md | 74 +++++++++++++++++- src-python/models/watchdog/watchdog.py | 102 ++++++++++++++++++++++--- 2 files changed, 164 insertions(+), 12 deletions(-) diff --git a/src-python/docs/modules/watchdog.md b/src-python/docs/modules/watchdog.md index 26a822bd..92a05669 100644 --- a/src-python/docs/modules/watchdog.md +++ b/src-python/docs/modules/watchdog.md @@ -3,10 +3,78 @@ 目的: 外部(Process 管理側)へ定期的に "生存" を知らせるために使う軽量ウォッチドッグ。 設計: -- class Watchdog(timeout:int=60, interval:int=20) +- class Watchdog(timeout: int = 60, interval: int = 20) - feed(): 最終フィード時刻を更新 - - setCallback(callback): タイムアウト時に呼ぶコールバックを登録 - - start(): 現状は単純で、呼び出し側がループ中に start() を呼ぶかたち。実装は簡易(将来的にスレッド化推奨) + - setCallback(callback): タイムアウト時に呼ぶコールバックを登録(zero-arg を想定) + - start(): 単一チェックを行い、`interval` 秒の sleep を行う(継続監視は呼び出し側でループまたはスレッド化) 注意: - 現行実装は非常にシンプルで、長時間のブロッキングやスレッド運用の見直しが必要になり得る。 + +変更点(実装に入れた改善): +- コールバック属性を初期化しておくことで AttributeError を防止 +- コールバック呼び出し内の例外はウォッチドッグ本体に影響を与えないよう try/except で保護 +- メソッドに型注釈と docstring を追加 + +短い使用例(ポーリング方式): + +```py +import time +from models.watchdog.watchdog import Watchdog + +def on_timeout(): + print('watchdog timed out') + +wd = Watchdog(timeout=5, interval=1) +wd.setCallback(on_timeout) + +# 別スレッドにせず、単純なループでポーリングする例 +while True: + wd.start() # ここで timeout をチェックし、必要なら callback を呼ぶ + # アプリケーションの他処理... + time.sleep(0.5) + + # 正常時に feed を呼ぶ例 + # wd.feed() +``` + +使用例(スレッド化ヘルパを用意するアプローチ): + +```py +import time +from threading import Thread, Event +from models.watchdog.watchdog import Watchdog + +stop_event = Event() + +def run_watchdog(wd: Watchdog, stop_event: Event): + # シンプルなバックグラウンド実行ループ(安全な停止用フラグ付き) + while not stop_event.is_set(): + wd.start() + +wd = Watchdog(timeout=10, interval=1) +wd.setCallback(lambda: print('timed out')) +thread = Thread(target=run_watchdog, args=(wd, stop_event), daemon=True) +thread.start() + +# 正常動作時 +wd.feed() +time.sleep(2) + +# 停止する場合は stop_event.set() を呼ぶ +stop_event.set() +thread.join() +``` + +拡張案(将来の改善): +- `start_in_thread()` / `stop()` を Watchdog に組み込む(内部で Thread と Event を管理して安全に停止できるようにする) +- コールバックに引数を渡せるようにする(context 情報、呼び出し回数など) +- asyncio と相互運用できるバージョン(async/await ベース)を用意する +- ロギング統合(標準 logging を使って状態変化を記録) +- 単発(one-shot)/繰り返しの動作モード指定 + +簡易テスト済み: +- 基本的なコールバックの有効/無効挙動をローカルで確認済み(feed 後は呼ばれず、タイムアウト状態で呼ばれる)。 + +注意事項: +- フル自動化(CI での運用)を行う場合は、スレッド起動・停止のテストを追加することを推奨します。 diff --git a/src-python/models/watchdog/watchdog.py b/src-python/models/watchdog/watchdog.py index 73803e10..5976005d 100644 --- a/src-python/models/watchdog/watchdog.py +++ b/src-python/models/watchdog/watchdog.py @@ -1,20 +1,104 @@ -from typing import Callable +from typing import Callable, Optional import time +from threading import Thread, Event + class Watchdog: - def __init__(self, timeout:int=60, interval:int=20): + """A lightweight watchdog utility. + + This class provides a minimal watchdog which records the last "feed" + timestamp and can invoke a user-supplied callback when the timeout + is exceeded. The design is intentionally simple: callers are expected + to either call `start()` periodically (e.g. from a loop) or extend the + class to run `start()` in a background thread. + + Args: + timeout: seconds without feed after which the callback is invoked + interval: suggested sleep interval (seconds) for callers that poll + """ + + def __init__(self, timeout: int = 60, interval: int = 20) -> None: self.timeout = timeout self.interval = interval self.last_feed_time = time.time() + self.callback: Optional[Callable[[], None]] = None + # Background thread control + self._thread: Optional[Thread] = None + self._stop_event: Optional[Event] = None - def feed(self): + def feed(self) -> None: + """Refresh the watchdog timer (set last feed time to now).""" self.last_feed_time = time.time() - def setCallback(self, callback): + def setCallback(self, callback: Callable[[], None]) -> None: + """Register a zero-argument callback invoked on timeout.""" self.callback = callback - def start(self): - if time.time() - self.last_feed_time > self.timeout: - if isinstance(self.callback, Callable): - self.callback() - time.sleep(self.interval) \ No newline at end of file + def start(self) -> None: + """Perform a single watchdog check and optionally sleep `interval` seconds. + + The method checks if the duration since the last feed exceeds + `timeout`. If so and a callback is registered, the callback is called. + + Note: `start()` does not run in the background by itself; callers + should call it repeatedly (or run it inside a thread) if continuous + monitoring is required. + """ + now = time.time() + if now - self.last_feed_time > self.timeout: + if callable(self.callback): + try: + self.callback() + except Exception: + # Do not let callback exceptions propagate out of watchdog + import traceback + traceback.print_exc() + time.sleep(self.interval) + + def _run_loop(self) -> None: + """Internal run loop used by `start_in_thread`. + + It repeatedly calls `start()` until `_stop_event` is set. The + implementation relies on `start()` sleeping for `self.interval`. + """ + # Defensive: ensure stop_event exists + if self._stop_event is None: + return + while not self._stop_event.is_set(): + self.start() + + def start_in_thread(self, daemon: bool = True) -> None: + """Start the watchdog in a background thread. + + If the watchdog is already running, this is a no-op. The created + thread will repeatedly call `start()` until `stop()` is invoked. + + Args: + daemon: if True, thread is a daemon thread (won't block process exit) + """ + if self._thread is not None and self._thread.is_alive(): + return + self._stop_event = Event() + self._thread = Thread(target=self._run_loop, daemon=daemon) + self._thread.start() + + def stop(self, timeout: Optional[float] = None) -> None: + """Stop background thread started by `start_in_thread`. + + If no background thread is running this is a no-op. + + Args: + timeout: optional timeout to wait for thread join (seconds). If + None, join will block until the thread exits. + """ + if self._stop_event is None or self._thread is None: + return + # signal stop and wait for thread to finish + self._stop_event.set() + self._thread.join(timeout=timeout) + # cleanup + if self._thread.is_alive(): + # thread did not stop within timeout; leave objects for another stop() + return + self._thread = None + self._stop_event = None \ No newline at end of file