sue@blog ~ /posts/windowserver-watchdog-panic
Engineer
Business
$ cd ../ 記事一覧に戻る
Business Mode — 技術的な詳細を省略し、全体の流れと「何をすればいいか」に集中した表示です。右上のトグルでEngineerモードに切り替えられます。
$ cat post.metadata

Macが自分で自分を再起動した — WindowServer監視タイムアウト・パニックの解剖

date: 2026-07-25 WindowServer Kernel Panic macOS Watchdog Troubleshooting
深夜、48GBのM4 Proが突然カーネルパニックで再起動した。犯人はハード故障ではなく、「画面サーバーが120秒応答しないのを検知して、機体ごと落とす」macOSの安全装置だった。パニックログ・Jetsam・4連発したスピンダンプから、GPUを奪ったローカルLLMと大量の並列プロセスに辿り着くまでの解剖記録。

💡 ひとことで言うと
パソコンが深夜に突然再起動した。原因を調べたら、「壊れた」のではなく、Mac自身の安全装置がわざと落としていたという話です。

画面を管理する担当者(WindowServer)が2分間まったく反応しなくなり、見張り役が「これはもう固まっている」と判断して強制的に再起動をかけた。では、なぜ画面担当は固まったのか——遡っていくと、自分のPCの中で動かしていたAI(ローカルLLM)と、大量に走らせていた自動処理に行き着きました。

深夜3時36分、作業していないはずのMacが突然真っ暗になり、勝手に再起動した。ビーチボール(虹色のくるくる)が回った記憶もない。ただ、落ちた。

再起動後にパニックレポートを開くと、1行目にこう書いてあった。

panic report(先頭)
panic(cpu 0 caller ...): userspace watchdog timeout:
no successful checkins from WindowServer (2 induced crashes) in 120 seconds

この1行が、実は答えのほぼ全部を言っている。この記事は、ハードが壊れたわけでもmacOSがバグったわけでもないこのパニックが、なぜ・何によって起きたのかを、実際のログだけを証拠にして最後まで追った記録です。前回の記事「48GBのMacがスワップ地獄に落ちた」の続編——同じマシン、同じ犯人が、今度はマシンごと落とした話でもあります。

対象読者

📋 ざっくり言うと、起きたことはこの3つ
  1. 画面を管理する担当者が、2分間フリーズした(応答が完全に止まった)
  2. 見張り役がそれを検知し、安全のためわざとPCを再起動した(=壊れたのではなく仕様)
  3. フリーズの根っこは、PC内のAIと自動処理の"働かせすぎ"(メモリ・GPU・CPUの奪い合い)

まず結論

これはハード故障でもmacOSのバグでもなく、macOSの自己回復機構が正常に働いた結果です。 画面描画を司る WindowServer が120秒間フリーズしたため、監視デーモン watchdogd が「GUIが完全にハングした」と判断し、意図的にカーネルパニックを起こして機体を再起動しました。凍りついた画面の前でユーザーが手も足も出なくなる状態を避けるための、いわば"非常ブレーキ"です。

そしてWindowServerがフリーズした根本原因は、単一のバグではなく資源の枯渇でした。GPUを長時間占有したローカルLLMと、同時に走っていた大量のエージェント/自動ループが、メモリ・GPU・CPUを奪い合い、WindowServerのメインスレッドが79秒間1度も動けなかった——これが直接の引き金です。


そもそも watchdog パニックとは何か

macOSには、重要なシステムサービスが生きているかを常時見張る watchdogd という監視役がいます。各サービスは定期的に「生存報告(check-in)」を送る決まりになっていて、WindowServer(画面・入力・描画を一手に引き受けるサービス)もその監視対象です。

WindowServerが報告を止めると、watchdogdはいきなり諦めるのではなく、段階的に手を打ちます。まずWindowServerをわざとクラッシュさせて再起動を試み("induced crash")、蘇生を狙う。それでも合計120秒チェックインが来なければ、最後の手段としてカーネルごとパニックさせて機体を再起動する。これがパニックログ1行目の 2 induced crashes in 120 seconds の意味です。

[図1]userspace watchdog の判定フロー
WindowServer  —— 定期的に生存報告(check-in) ——→  watchdogd
 
[報告が途絶える]
  
① 誘発クラッシュ #1  WindowServerをわざと落として蘇生を試みる
    まだ報告が来ない
② 誘発クラッシュ #2  もう一度蘇生を試みる
    それでも 120秒 沈黙
③ カーネルパニック → 強制再起動  (最後の非常ブレーキ)

実際、パニックログの他サービスは全員が「0秒前に正常報告」で健全でした。詰まっていたのはWindowServerただ1つです。

panic report(サービスのチェックイン状況)
service: logd,           last successful checkin: 0 seconds ago
service: opendirectoryd, last successful checkin: 0 seconds ago
service: configd,        last successful checkin: 0 seconds ago
service: WindowServer (2 induced crashes), last successful checkin: 120 seconds ago  ← これだけ死んでいる
🚨 たとえるなら「非常ブレーキ」
電車の運転士(WindowServer)が、決められた間隔で押すはずの「生きてます」ボタン(デッドマン装置)を押さなくなった。

車掌(watchdogd)はまず何度か声をかけ、揺り起こそうとします。それでも2分間まったく反応がない。ここで車掌が下す判断は「様子見」ではなく「非常ブレーキを引いて電車を止める」。乗客を乗せたまま制御不能で走り続けるより、いったん止めた方が安全だからです。

今回のMacの再起動は、この非常ブレーキです。 壊れたから止まったのではなく、止めた方が安全だから、設計通りに止めた

証拠はどこにあるか:3種類の診断レポート

パニックの"証言"は、貼り付けられたテキスト1枚だけではありません。macOSは /Library/Logs/DiagnosticReports/ に、事件の前後を記録した複数のファイルを残します。まずそれを一覧する。

Terminal
ls -lt /Library/Logs/DiagnosticReports/ | head
出力(抜粋・時刻順)
03:36:53  WindowServer_...033653_...userspace_watchdog_timeout.spin   ← 致命傷
03:36:50  WindowServer-2026-07-25-033650.ips
03:36:14  WindowServer_...033614_...userspace_watchdog_timeout.spin
03:36:10  WindowServer-2026-07-25-033610.ips
03:05:23  WindowServer_...030523_...userspace_watchdog_timeout.spin
03:04:46  WindowServer_...030446_...userspace_watchdog_timeout.spin
03:02:45  JetsamEvent-2026-07-25-030245.ips                          ← メモリ逼迫キル

ここで押さえる3種類:

一覧を見ただけで、これが一発の事故ではなく、30分以上続いた断末魔だったと分かります。03:02にメモリ逼迫、03:04と03:05に画面が固まり(一度は復帰)、03:36に再び固まって、今度は復帰できずにパニック。時系列に並べるとこうなる。

[図2]事件のタイムライン(2026-07-25 深夜)
02:40   起動(boot)
        約56分、負荷がじわじわ上昇
03:02:45  Jetsam発火 — 空きメモリ約15MB/wired約31.7GB。最大喰いは ollama
03:04:46  WindowServer 監視タイムアウト #1(36秒フリーズ)
03:05:23  WindowServer 監視タイムアウト #2(76秒フリーズ)… ここは一度復帰
        約30分後、再燃
03:36:14  WindowServer 監視タイムアウト #3(39秒フリーズ)
03:36:53  #4(79秒フリーズ)→ カーネルパニック
03:37    自動再起動 ✓

なぜWindowServerは固まったのか — spindumpを読む

ここが解剖のヤマです。4つの .spin を開くと、WindowServerのメインスレッドが4回とも同じ場所に張り付いていた。これが決定打でした。

spindump(致命の033653・WindowServerメインスレッド)
Thread ...  DispatchQueue "com.apple.SkyLight.mtl_submit"
            Thread name "ws_main_thread"   # 画面描画の心臓部
            12 samples (1-12)   last ran 79.204s ago   # 79秒間1度も動いていない

  → SkyLight ... → Metal ... → libdispatch ... → Metal ... → IOGPU
  # = GPUへ描画コマンドを投入して、その完了を待ったまま返ってこない

読み解くポイントは3つ。

そして同じ mtl_submit 停止が、34分間に4回。フリーズ時間は 36 → 76 → 39 → 79 秒と伸びていく。偶然ではなく、単一原因の再発です。

4つのspindumpは全部同じ場所で固まっていた
03:04:46  ws_main_thread / com.apple.SkyLight.mtl_submit   last ran 36.757s ago
03:05:23  ws_main_thread / com.apple.SkyLight.mtl_submit   last ran 76.758s ago
03:36:14  ws_main_thread / com.apple.SkyLight.mtl_submit   last ran 39.203s ago
03:36:53  ws_main_thread / com.apple.SkyLight.mtl_submit   last ran 79.204s ago  ← ここでパニック

もう一つ効いている事実:致命の .ips には displayState: OFF とありました。画面が消えている(スリープ状態)にもかかわらず、WindowServerの最小限のGPU投入すら完了できなかった。これは「WindowServerが重い描画をしていた」のではなく、GPUを別の誰かがガッチリ握っていたことを強く示唆します。

🛗 たとえるなら「1台しかない印刷機」
画面担当(WindowServer)は、絵を映すたびに共用の印刷機(GPU)に「これ刷って」と依頼します。ふだんは一瞬で返ってくる。

ところがこの夜は、別の誰かが大量の印刷ジョブで印刷機を占領していて、画面担当の順番が79秒経っても回ってこなかった。画面担当は行列に並んだまま何もできず、その間「生きてます」の報告も出せなかった——だから見張り役に「死んだ」と判定された、というわけです。

ポイントは、画面担当はサボっていないこと。 順番を奪われて、待たされていただけです。

GPUと共に、メモリとCPUも枯れていた

GPUの取り合いが直接の引き金ですが、この夜のMacはメモリもCPUも同時に枯れていました。3方向から締め上げられていた、というのが正確な絵です。

まずメモリ。03:02のJetsamレポートを開くと、逼迫の数字が壮絶でした。

JetsamEvent-030245.ips(メモリの状態・16KBページ換算)
空きページ:        930 pages     → 約 15 MB しか空いていない
wired(固定):      2,076,152     → 約 31.7 GB がロックされて動かせない
compressions:      18,383,982    → 圧縮でしのごうとした回数(=もう限界)
largestProcess:    "ollama"      → メモリ最大喰いは ollama と名指し
→ killed: MTLCompilerService / sirittsd / tccd  (メモリ確保のため強制終了)

空きメモリ約15MB。48GB積んだマシンで、である。パニックログ側にも 26 swapfiles(=重いスワップが動作中)という記録が残っていて、メモリ逼迫は疑いようがありません。

次にCPU。パニック時のトップCPUスレッドは Pythonbunbiome×2。さらに再起動直後の負荷を測ると、12コアのマシンで load average 120 という異常値。ロードアベレージは「CPUの順番待ち行列の長さ」なので、120は12コアに対して桁違いの過積載を意味します。

Terminal(再起動4分後)
$ uptime
3:41  up 4 mins,  load averages: 120.80 70.32 29.65   # 12コアに対して120

WindowServerのメインスレッドは、GPUに阻まれ、CPUの順番待ちに阻まれ、逼迫したメモリのページフォールトに阻まれた。3つが同時に効いた結果が79秒の沈黙です。

[図3]WindowServerを締め上げた3方向
メモリ逼迫
空き約15MB/wired 31.7GB/26 swapfiles。ページが即座に戻ってこない。
GPU占有
mtl_submit → IOGPU で79秒待ち。描画コマンドの投入が返らない。
CPU過負荷
load 120/12コア。実行の順番がなかなか回ってこない。
↓ 三方向から同時に締める ↓
WindowServer メインスレッドが79秒停止 → 監視タイムアウト

犯人の裏取り:誰がGPUとメモリを奪ったか

ここまでで「資源が枯れてWindowServerが固まった」まではログで直接確認できた事実です。では、資源を奪った当人は誰か。証拠を積みます。

Jetsamレポートは largestProcess: "ollama" と名指ししていました。ollamaは自分のPCの中でLLM(大規模言語モデル)を動かすツールで、このマシンではClaudeがレート制限に当たったときのローカルフォールバック(Qwen系9Bモデル)として常用しています。9Bモデルの推論はAppleのGPU(Metal)を長時間占有し、メモリも数GB~十数GB単位でロックします。displayState: OFF でもGPUが握られていた説明として、これは強く噛み合います。実際、spindumpの脇には com.local.ollama throttled(ollamaがOSに絞られている)というログも残っていました。

もう一方の当事者が大量の並列プロセス。パニック時のトップCPUは Python・bun・biome×2。再起動後に数えると node が44プロセス、複数のPython、bun。加えて、自動で回しているlaunchdのループ(cron的な定期実行)が12本登録されていました。これらがヘッドレスの処理を吐き、複数の作業セッションと重なって、CPUとメモリを奪い合った。

Terminal(再起動後の実測)
$ launchctl list | grep -cE 'com\.(mosh|teto)\.'
12                              # 定期実行ループが12本

$ pgrep -x node | wc -l
44                              # node が44プロセス
正直な確度の話。 「資源枯渇でWindowServerが固まった」はログで直接確認できた事実です。一方で「ollamaのGPU処理がその瞬間のGPUを独占していた」は、ollamaのGPU投入そのものはこれらのレポートには写らないため、状況証拠に基づく最有力の推定です(Jetsamの名指し+このマシンのollama常用+displayState OFFでのGPU占有)。断定はしません。ただ、対策の当て先としては十分に確度が高い。

ハード故障ではない — 除外した仮説

「勝手に再起動」と聞くと真っ先にハード故障やGPUの死を疑いたくなります。が、ログはそれを否定していました。

疑った仮説ログが示した反証
GPU/ハードの故障4回とも「投入が返ってこない待ち」であって、GPUリセット/クラッシュのログは無し
発熱(サーマル)由来thermalPressureLevel: Nominal (0) = 発熱は正常範囲
サードパーティのカーネル拡張(kext)バックトレースはApple製の SkyLight / Metal / IOGPU のみ。外部ドライバは不在
macOSそのもののバグwatchdogは仕様通りに発火。他サービスは全員健全。単なる過負荷
[図4]因果の4層(下ほど根っこ)
結果    カーネルパニック → 自動再起動
      ↑ watchdogが検知して発火(=安全装置・正常動作)
直接    WindowServerが mtl_submit(GPU投入)で79秒停止
      ↑ 資源を奪われて動けない
環境    メモリ逼迫(空き15MB)+ GPU占有 + CPU過負荷(load 120)
      ↑ 何がその負荷を作ったか
引き金  ローカルLLM(ollama) + 大量の並列エージェント/node + 12本の定期ループ

対策 — 効く順に

当て先ははっきりしています。「48GBのM4 Proに対して、GPU常駐LLM+大量の並列処理は過積載」。効く順に並べます。

1ローカルLLMをGPUに常駐させない。 これが一番効く。使用後はモデルを確実にアンロードし、"居座り時間"をゼロにする。より小さい量子化モデルに落とすだけでもGPU占有は激減します。

Terminal
# 使い終わったモデルを即アンロード
$ ollama stop <model>

# そもそも居座らせない:使用後すぐ解放(keep_alive=0)
$ OLLAMA_KEEP_ALIVE=0 ollama serve

# サービス常駐しているなら、必要時だけ起動に切り替える
$ launchctl bootout gui/$UID/com.local.ollama

2並列ヘッドレス処理の同時本数を絞る。 12本の定期ループ+複数セッション+並列エージェントが同じ分に一斉発火する構成は、48GBには過積載。起動時刻を分散し、同時実行に上限を設ける。

launchd plist(起動時刻を散らす例)

<key>StartCalendarInterval</key>
<array>
  <dict><key>Minute</key><integer>13</integer></dict>
  <dict><key>Minute</key><integer>43</integer></dict>
</array>

3「逼迫したら自分で絞る」ガードを1本入れる。 空きメモリ%・compressor・swapfile数がしきい値を超えたら、重いループを一時停止する軽い番人。Jetsamが走るに自衛できます。

memory-guard(考え方)
# 空きが閾値を割ったら重いループを止める(擬似コード)
FREE=$(memory_pressure | awk '/free percentage/{print $5}')
if [ "${FREE%\%}" -lt 15 ]; then
  launchctl bootout gui/$UID/com.local.ollama
  pause_heavy_loops   # 定期ループを一時停止
fi

4再燃したら即・沈静化。 再起動直後も load 120 だったように、また同じ条件が揃っていることがある。暴れているプロセスを落とし、Spotlightの再索引(mds_stores)が収まるのを待つ。

Terminal
$ ps -Ao pid,%cpu,comm -r | head   # CPU上位を特定して落とす
🧯 やることを一言で
「一番広い部屋を押さえっぱなしのAIを、使い終わったら必ず退室させる」。これだけで大半は解決します。

加えて、自動処理を"同じ時刻に一斉に走らせない"(時間をずらす)、部屋が埋まってきたら自動でいくつか止める番人を置く。48GBという部屋数に対して、住人を増やしすぎない——それが本質です。

まとめ — 監視が「正しく」嘘をつかなかった話

私はふだん「監視は緑でも壊れていることがある」を警戒しています。ダッシュボードが正常を示していても、実際のユーザー体験は死んでいる——そういうサイレント障害を何度も見てきたからです。

今回はそのでした。watchdogは正直だった。「WindowServerは120秒応答していない」という観測可能な事実を、忖度なく検知し、設計通りに非常ブレーキを引いた。フリーズした画面の前で無限に固まるより、記録を残して再起動する方が正しい。これは"壊れた"のではなく、"守った"

本当に直すべきはその下、資源の使い方です。ローカルLLMをGPUに常駐させ、大量の並列処理を同じ時刻に走らせれば、48GBのM4 Proでも簡単に枯れる。派手なクラッシュの1行目の裏には、たいてい静かで平凡な過積載がいる。前回のollama記事と、犯人はまったく同じでした。

次に同じ再起動が起きたら、まずこの2点を見れば一発で当たりが付きます。
JetsamEvent-*.ipslargestProcess(メモリを一番喰っていた誰か)
② WindowServerの .spinmtl_submit / last ran ...s ago(GPU待ちで固まっていたか)
$ _