Skip to content

fix(configure): non-blocking /api/updates + periodic update poll for the always-on service#245

Merged
hyoshi merged 4 commits into
mainfrom
fix/api-updates-non-blocking
Jun 13, 2026
Merged

fix(configure): non-blocking /api/updates + periodic update poll for the always-on service#245
hyoshi merged 4 commits into
mainfrom
fix/api-updates-non-blocking

Conversation

@hyoshi

@hyoshi hyoshi commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

mureo configurepip list --outdated(この環境では60秒タイムアウト)がリクエスト毎に同期実行され、UI起動の度にブロック+トレースバック多発+BrokenPipe を起こしていた問題への対応。2コミットで構成。

1) 非ブロッキング化(fix)

GET /api/updatespip list --outdated --format=jsonリクエスト毎に同期実行。遅い/到達不能な index ではこのサブプロセスが最大60秒かかり、configure UI は1ページロードあたり複数回フェッチするため、毎回リクエストスレッドをブロック→TimeoutExpired のフルトレースバックを連発→ブラウザが応答途中で切断 (BrokenPipeError)。

  • version_check.py: 非ブロッキングアクセサ get_update_status() を追加。遅い check_for_updates()デーモンスレッドで1回だけ実行しキャッシュ(TTL: 成功6h / 失敗10min)。ハンドラは即返却、cold時は status="checking"(フロントは非okを無視)。_refresh_in_progress の single-flight で「1ページロードで複数pip」を防止。共有状態はロック保護、戻り値はコピー。
  • timeout/OSError のログを logger.exception(多行トレースバック)→ logger.warning(1行)に。
  • handlers.py: /api/updatesget_update_status() に切替。コア check_for_updates() は不変。

2) 常時起動サービス向け定期ポーリング(feat)

非ブロッキングキャッシュはUIを開いたときしか温まらない。常時起動サービス(--serve / launchd / systemd)では誰もUIを開かないためキャッシュが cold のままで、更新バッジが手動アクセスまで出ない。

  • version_check.py: start/stop_periodic_update_check() がデーモンスレッドで「起動直後に1回 → 以降 interval 毎」にキャッシュを温める。デフォルト6時間MUREO_UPDATE_CHECK_INTERVAL_SECONDS で上書き(0/負で無効、非finiteはデフォルトにフォールバック)。lazy 経路と single-flight ゲート (_refresh_if_idle) を共有し二重実行を防止。各起動が自前の stop Event を持つので stop/start 競合でポーラーが取り残されない。daemon スレッドなので shutdown の join はベストエフォート。
  • server.py: run_configure_wizardserve_forever モードのみ、サーバ ready 後にポーリング開始・finally で停止。短命な対話起動は従来の「UI起動時 lazy チェック」のまま。

設計判断(更新マークのタイミング)

  • 更新チェックは定期ポーリング無しだった既存実装では「UIを開く度(ページロード/dashboard遷移/言語切替/ステータス更新後)」に発火。本PRで常時起動サービスのみ定期ポーリングを追加。
  • 常時起動でもサーバが勝手にpipを叩き続けることはなく、cadence は最大6h。新版適用後はサービス再起動でキャッシュ(プロセスメモリ)がリセットされる。

Test plan

  • pytest tests/test_web_version_check.py tests/test_web_handlers.py tests/test_web_server.py tests/test_configure_serve.py234 passed
  • ruff / black --check クリーン
  • スモーク: アクセサは 0.2ms 即返却(60秒ブロックなし)
  • 新規テスト: 非ブロッキング(cold/キャッシュ/error TTL/stale/並行 single-flight)+ 定期ポーリング(interval解決 arg/env/default/invalid、0=無効、即時warm、idempotent、start→stop→start、single-flight skip)
  • serve テストは autouse フィクスチャで実pipを起動しない

Review

code-reviewer を各コミット前に実施。指摘の thread-safety 項目を都度修正:

  • 非ブロッキング: _refresh_thread のロック内書き込み / warm-path のコピー返し / 並行 single-flight テスト追加
  • 定期ポーリング: module-level stop event の世代間エイリアシング競合 → スレッド毎 stop Event に変更、非finite interval ガード、再起動テスト追加

hyoshi added 3 commits June 13, 2026 16:56
…ng the UI

`GET /api/updates` ran `pip list --outdated --format=json` synchronously on
every request. On a slow or unreachable index that subprocess takes up to its
60s timeout, and the configure UI fetches the endpoint more than once per page
load — so each `mureo configure` blocked the request thread repeatedly, dumped
a full TimeoutExpired traceback to the console per attempt, and the browser
gave up mid-response (BrokenPipeError).

Fix:
- version_check.py: add a non-blocking accessor `get_update_status()` that runs
  the slow `check_for_updates()` once in a daemon thread and caches the result
  (TTL: 6h ok / 10min error). The handler returns instantly; a cold cache
  yields `status="checking"`, which the frontend already treats as "nothing to
  show". Single-flight via `_refresh_in_progress` prevents the per-page-load
  thundering herd. All shared cache state is lock-guarded; the accessor returns
  a copy so a caller can't corrupt the cache.
- Downgrade the expected timeout/OSError log from `logger.exception` (multi-line
  traceback) to a single `logger.warning` line.
- handlers.py: `/api/updates` now calls `get_update_status()`.
- Core `check_for_updates()` is unchanged.

Tests: update the 3 handler route tests to patch `get_update_status`; add cache-
layer tests (cold-start placeholder, caching, error TTL, stale refresh, and the
concurrent single-flight invariant).
…rvice

The non-blocking `/api/updates` cache is populated lazily — only when the UI is
opened. For the always-on service (`mureo configure --serve` / launchd / systemd)
nobody opens the UI, so the cache stays cold and the update badge never appears
until a manual visit.

Add a periodic poll that warms the cache on a wide cadence:
- version_check.py: `start_periodic_update_check()` / `stop_periodic_update_check()`
  run a daemon thread that refreshes the cache immediately, then once per
  interval. Default 6h, overridable via `MUREO_UPDATE_CHECK_INTERVAL_SECONDS`
  (0/negative disables; non-finite values fall back to the default). The poll
  shares the lazy path's single-flight gate (`_refresh_if_idle`) so the two
  never double-run, and each launch owns its own stop Event so a stop/start
  race can't strand a poller. Threads are daemons; the shutdown join is
  best-effort.
- server.py: `run_configure_wizard` starts the poll after the server is ready
  ONLY in `serve_forever` mode, and stops it in the `finally`. Short-lived
  interactive launches keep relying on the lazy on-open check.

Tests: TestPeriodicUpdateCheck covers interval resolution (arg/env/default/
invalid), the 0=disabled switch, immediate cache warm, idempotent start,
start→stop→start restart, and the single-flight skip. test_configure_serve.py
gets an autouse fixture disabling the poll so serve tests never spawn real pip.
@hyoshi hyoshi changed the title fix(configure): make /api/updates non-blocking so a slow pip can't hang the UI fix(configure): non-blocking /api/updates + periodic update poll for the always-on service Jun 13, 2026
@hyoshi
hyoshi merged commit eb90f2e into main Jun 13, 2026
9 checks passed
@hyoshi
hyoshi deleted the fix/api-updates-non-blocking branch June 13, 2026 08:43
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.

1 participant