深夜3時36分、作業していないはずのMacが突然真っ暗になり、勝手に再起動した。ビーチボール(虹色のくるくる)が回った記憶もない。ただ、落ちた。
再起動後にパニックレポートを開くと、1行目にこう書いてあった。
panic(cpu 0 caller ...): userspace watchdog timeout:
no successful checkins from WindowServer (2 induced crashes) in 120 seconds
この1行が、実は答えのほぼ全部を言っている。この記事は、ハードが壊れたわけでもmacOSがバグったわけでもないこのパニックが、なぜ・何によって起きたのかを、実際のログだけを証拠にして最後まで追った記録です。前回の記事「48GBのMacがスワップ地獄に落ちた」の続編——同じマシン、同じ犯人が、今度はマシンごと落とした話でもあります。
.panic / .ips / .spin レポートを読み解けるようになりたい方WindowServer が120秒間フリーズしたため、監視デーモン watchdogd が「GUIが完全にハングした」と判断し、意図的にカーネルパニックを起こして機体を再起動しました。凍りついた画面の前でユーザーが手も足も出なくなる状態を避けるための、いわば"非常ブレーキ"です。
そしてWindowServerがフリーズした根本原因は、単一のバグではなく資源の枯渇でした。GPUを長時間占有したローカルLLMと、同時に走っていた大量のエージェント/自動ループが、メモリ・GPU・CPUを奪い合い、WindowServerのメインスレッドが79秒間1度も動けなかった——これが直接の引き金です。
macOSには、重要なシステムサービスが生きているかを常時見張る watchdogd という監視役がいます。各サービスは定期的に「生存報告(check-in)」を送る決まりになっていて、WindowServer(画面・入力・描画を一手に引き受けるサービス)もその監視対象です。
WindowServerが報告を止めると、watchdogdはいきなり諦めるのではなく、段階的に手を打ちます。まずWindowServerをわざとクラッシュさせて再起動を試み("induced crash")、蘇生を狙う。それでも合計120秒チェックインが来なければ、最後の手段としてカーネルごとパニックさせて機体を再起動する。これがパニックログ1行目の 2 induced crashes in 120 seconds の意味です。
実際、パニックログの他サービスは全員が「0秒前に正常報告」で健全でした。詰まっていたのはWindowServerただ1つです。
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 ← これだけ死んでいる
パニックの"証言"は、貼り付けられたテキスト1枚だけではありません。macOSは /Library/Logs/DiagnosticReports/ に、事件の前後を記録した複数のファイルを残します。まずそれを一覧する。
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種類:
.ips — クラッシュ/イベントの要約(JSON)。「何が・いつ・どのプロセスで」起きたか。.spin — フリーズしたプロセスのスタックダンプ(spindump)。「どこで固まっていたか」がスレッド単位で分かる。ハングの犯人はここに写る。JetsamEvent — メモリが尽きてOSがプロセスを強制終了(Jetsam)した記録。逼迫の度合いと"誰が一番食っていたか"が分かる。一覧を見ただけで、これが一発の事故ではなく、30分以上続いた断末魔だったと分かります。03:02にメモリ逼迫、03:04と03:05に画面が固まり(一度は復帰)、03:36に再び固まって、今度は復帰できずにパニック。時系列に並べるとこうなる。
ここが解剖のヤマです。4つの .spin を開くと、WindowServerのメインスレッドが4回とも同じ場所に張り付いていた。これが決定打でした。
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つ。
last ran 79.204s ago — メインスレッドが79秒間、一度も実行されていない。監視が120秒で切れるのも当然です。mtl_submit → Metal → IOGPU — GPUへの描画コマンド投入が返ってこない場所で止まっている。画面はGPU無しには描けないので、ここで詰まるとWindowServerは何もできません。そして同じ mtl_submit 停止が、34分間に4回。フリーズ時間は 36 → 76 → 39 → 79 秒と伸びていく。偶然ではなく、単一原因の再発です。
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を別の誰かがガッチリ握っていたことを強く示唆します。
GPUの取り合いが直接の引き金ですが、この夜のMacはメモリもCPUも同時に枯れていました。3方向から締め上げられていた、というのが正確な絵です。
まずメモリ。03:02のJetsamレポートを開くと、逼迫の数字が壮絶でした。
空きページ: 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スレッドは Python・bun・biome×2。さらに再起動直後の負荷を測ると、12コアのマシンで load average 120 という異常値。ロードアベレージは「CPUの順番待ち行列の長さ」なので、120は12コアに対して桁違いの過積載を意味します。
$ uptime
3:41 up 4 mins, load averages: 120.80 70.32 29.65 # 12コアに対して120
WindowServerのメインスレッドは、GPUに阻まれ、CPUの順番待ちに阻まれ、逼迫したメモリのページフォールトに阻まれた。3つが同時に効いた結果が79秒の沈黙です。
ここまでで「資源が枯れて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とメモリを奪い合った。
$ launchctl list | grep -cE 'com\.(mosh|teto)\.'
12 # 定期実行ループが12本
$ pgrep -x node | wc -l
44 # node が44プロセス
「勝手に再起動」と聞くと真っ先にハード故障やGPUの死を疑いたくなります。が、ログはそれを否定していました。
| 疑った仮説 | ログが示した反証 |
|---|---|
| GPU/ハードの故障 | 4回とも「投入が返ってこない待ち」であって、GPUリセット/クラッシュのログは無し |
| 発熱(サーマル)由来 | thermalPressureLevel: Nominal (0) = 発熱は正常範囲 |
| サードパーティのカーネル拡張(kext) | バックトレースはApple製の SkyLight / Metal / IOGPU のみ。外部ドライバは不在 |
| macOSそのもののバグ | watchdogは仕様通りに発火。他サービスは全員健全。単なる過負荷 |
当て先ははっきりしています。「48GBのM4 Proに対して、GPU常駐LLM+大量の並列処理は過積載」。効く順に並べます。
1ローカルLLMをGPUに常駐させない。 これが一番効く。使用後はモデルを確実にアンロードし、"居座り時間"をゼロにする。より小さい量子化モデルに落とすだけでもGPU占有は激減します。
# 使い終わったモデルを即アンロード
$ ollama stop <model>
# そもそも居座らせない:使用後すぐ解放(keep_alive=0)
$ OLLAMA_KEEP_ALIVE=0 ollama serve
# サービス常駐しているなら、必要時だけ起動に切り替える
$ launchctl bootout gui/$UID/com.local.ollama
2並列ヘッドレス処理の同時本数を絞る。 12本の定期ループ+複数セッション+並列エージェントが同じ分に一斉発火する構成は、48GBには過積載。起動時刻を分散し、同時実行に上限を設ける。
<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が走る前に自衛できます。
# 空きが閾値を割ったら重いループを止める(擬似コード)
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)が収まるのを待つ。
$ ps -Ao pid,%cpu,comm -r | head # CPU上位を特定して落とす
私はふだん「監視は緑でも壊れていることがある」を警戒しています。ダッシュボードが正常を示していても、実際のユーザー体験は死んでいる——そういうサイレント障害を何度も見てきたからです。
今回はその逆でした。watchdogは正直だった。「WindowServerは120秒応答していない」という観測可能な事実を、忖度なく検知し、設計通りに非常ブレーキを引いた。フリーズした画面の前で無限に固まるより、記録を残して再起動する方が正しい。これは"壊れた"のではなく、"守った"。
本当に直すべきはその下、資源の使い方です。ローカルLLMをGPUに常駐させ、大量の並列処理を同じ時刻に走らせれば、48GBのM4 Proでも簡単に枯れる。派手なクラッシュの1行目の裏には、たいてい静かで平凡な過積載がいる。前回のollama記事と、犯人はまったく同じでした。
JetsamEvent-*.ips の largestProcess(メモリを一番喰っていた誰か).spin の mtl_submit / last ran ...s ago(GPU待ちで固まっていたか)