Mac 発熱調査と自動停止の番人
「PC が熱を帯びている」という申告から、実測で熱源を 2 つ特定しました。11 時間 47 分にわたり 1 コアを焼き続けていた放置済みの Expo 開発サーバーと、445MB の 4K 動画を再生し続けていた OS 標準の「動く壁紙」です。両方を止め、前者については未使用時に自動停止する番人を実装して launchd に載せました。
観測時刻はすべて JST。調査 2026-09-16 10:53〜11:18。対象機は Darwin 25.6.0(macOS 26 系)。
経過 11h50m の 88%
2 日で消費
うち動画本体 445MB
10:53 → 11:17
1. 何が熱源だったか
瞬間の CPU 使用率ではなく 累積 CPU 時間(ps の TIME 列)で並べ替えると、犯人は一目で出ました。これが今回いちばん再利用できる手順です。
$ ps -Ao pid,user,time,%cpu,etime,command | (累積CPU秒に変換して降順) 10h28m 16157 node .../shushu-app/node_modules/.bin/expo start --dev-client --lan --port 8081 --clear 9h34m 6020 .../Virtualization.framework/.../com.apple.Virtualization.VirtualMachine 5h18m 406 .../SkyLight.framework/Resources/WindowServer -daemon 2h59m 678 .../WallpaperAerialsExtension.appex/Contents/MacOS/WallpaperAerialsExtension 1h51m 6063 .../Docker Desktop Helper (Renderer)
熱源 A: 放置された Expo 開発サーバー
| 項目 | 実測値 |
|---|---|
| PID | 16157 |
| 起動 | 2026-09-15 23:06:14 |
| 経過 | 11時間50分 |
| 累積 CPU | 10時間28分(経過の 88%) |
| 親プロセス | launchd(PID 1)= 起動元のターミナルは閉じられていた |
| 配下 | jest-worker × 6 が同時に残存 |
待機中の Metro が 1 コアを張り付かせるのは異常値です。watchman が未インストールで、ファイル監視が Node 自前の経路に落ちていたことが有力な原因ですが、この点は未確認です(サーバーが調査中に別セッションから再起動され、暴走状態の再現ができなくなったため)。
本質的な問題は暴走そのものではなく「孤児化」です。ターミナルを閉じても親が launchd に移るだけでプロセスは生き残ります。誰も見ていない・誰も止めない・止まる条件も無い。だから 11 時間走り続けました。次節の番人はここに効きます。
熱源 B: OS 標準の「動く壁紙」
当初「開発中の機能が使っているかもしれない」という見立てでしたが、調査の結果これは macOS 標準のエアリアル壁紙で、開発物とは無関係でした(リポジトリ全文検索でヒット 0。唯一の一致はポケモンのメガプテラの記事)。
| 項目 | 実測値 |
|---|---|
| 実体 | /System/Library/ExtensionKit/Extensions/WallpaperAerialsExtension.appex |
| 削除可否 | 不可(SIP 有効。csrutil status = enabled) |
| 動いていた理由 | 壁紙設定が macOS 既定のまま(Provider: default、LastSet 2024-11-10 から未変更) |
| 再生していた動画 | 4C108785-….mov = "Tahoe Day" 445MB(6016×6016) |
| アセット合計 | 457MB |
2. 実施した対処
止めたもの
- 壁紙を全 Space で静止画(
Mac Blue.heic)に変更 → 動画の再生が停止(拡張プロセス自体は常駐するが、✅ 実測 11:20:50〜11:21:41 の 2 分間、累積 CPU が0:00.26から 1 ミリ秒も動かない休眠状態) - アセット 457MB を削除(45 秒後の再確認で再ダウンロードなし)
- Expo サーバーは調査中に別セッションが再起動済み(現在 CPU 0.2%)
触れていないもの
- スクリーンセーバ(Drift)— 壁紙の設定単位を
linkedからindividualに分けて、Idle 側は元のまま残した - Supabase ローカルスタック 11 コンテナ(累積 9h34m)— 開発に使用中のため判断待ち
- iOS シミュレータ・claude セッション 5 本
3. 番人: bin/expo-idle-guard.sh
「シミュレータでの動作確認に使っている、もしくは iPhone が刺さっている、のどちらも満たさないなら自動停止する」という指示に対する実装です。5 分おきに launchd が起動します。
「使っている」の判定
| 条件 | 判定方法 | 実測所要 |
|---|---|---|
| (a) シミュレータが起動中 | xcrun simctl list devices booted に (Booted) | 0.28 秒 |
| (b) iOS 実機が USB 接続 | ioreg -rc IOUSBHostDevice に SupportsIPhoneOS | 0.015 秒 |
| (c) ビルド進行中 | expo run:ios / xcodebuild / eas build の存在 | — |
1 つでも真なら停止しません。(c) は指示に無い追加条件ですが、調査中に実際に事故りかけたため入れました。10:55 に別セッションが expo run:ios を開始した直後で、ビルドは 8081 の Metro を掴むため、巻き込むと「原因不明で、しかも再現しないビルド失敗」になります。
実機判定に xcrun devicectl(USB と Wi-Fi を transportType で区別できる)を採らず ioreg にしたのは、devicectl が数秒かかり Xcode の在否に依存するためです。代償として Wi-Fi ペアリングのみの端末は「刺さっていない」と判定されますが、これは指示の文言どおりです。
止め方
- 1 回の不在では止めない。連続 2 回(=最短 5 分・最長 15 分)観測して初めて停止する。シミュレータ再起動や端末の抜き差しで一瞬不在になるのは正常で、そこで殺すと人は仕組みごと無効化する
- 起動から 10 分未満は見送る。起動処理中を殺さない
- プロセスグループごと TERM → 10 秒待つ → KILL。npx → npm → node → jest-worker×6 の一族をまとめて落とす。PGID が安全でないときは個別 PID に落とす
- 黙って殺さない。停止ログに「何を・いつ起動した・累積 CPU いくつのものを」を残し、後から妥当性を判断できるようにする
受け入れテスト
bin/expo-idle-guard.test.sh — 20 項目すべて通過。本物の node プロセスを立てて実際に停止できることまで確認しています(モックの戻り値照合では「プロセスグループごと落とす」の検証にならないため)。
🔴 このテストは一度、本番環境を壊しました。初版はスタブで「シミュレータなし・実機なし」を装った瞬間、番人がマシン全体を走査して、テスト用の偽サーバーだけでなく別セッションが使っていた本物の Expo サーバー(PID 90932、ポート 8081)まで停止させました。
対策として本体に EXPO_GUARD_MATCH(走査範囲をコマンドラインの部分一致で限定する口)を追加し、テストは必ず自分の作業場に閉じるようにしました。本番では空のまま=マシン全体が対象です。テストが本番環境を壊せる時点でテストではありません。
実装中に踏んだもう 1 つの罠
--install が終了コード 1 で落ちました。原因は say "ラベル : $LABEL(5分おき)" の 1 行で、bash が全角括弧まで変数名の一部として読み、set -u 下で未定義変数エラーになったものです。LaunchAgent 自体は読み込みに成功していたため、「失敗したように見えて半分成功している」という最悪の壊れ方をしました。日本語でログを書く以上必ず起きるので、変数は ${VAR} で囲む規約を設計の芯に書き込んであります。
4. 使い方
bin/expo-idle-guard.sh --status # いまの判定材料とストライク数を見る bin/expo-idle-guard.sh --dry-run # 判定だけして停止はしない(状態も書き換えない) bin/expo-idle-guard.sh --uninstall # launchd から外す bash bin/expo-idle-guard.test.sh # 受け入れテスト(20項目) tail -f ~/.local/state/personal-os/logs/expo-idle-guard.log # 停止の記録
ログは「使用中」のときは 1 行も書きません(沈黙)。STRIKE・RESET・STOP・SKIP のときだけ記録します。
5. 残っている判断
| 対象 | 実測 | 提案 |
|---|---|---|
| Supabase ローカルスタック(11 コンテナ) | VM 累積 CPU 9h34m / 45 時間連続稼働 | 使わない時間帯は supabase stop。常時稼働が要るかは事業側の判断 |
| iOS シミュレータ | iPhone 17 Pro が起動中 | 実機で確認しているなら落とす |
| claude セッション 5 本 | 最古 1 日 18 時間半 / 付随 MCP 10 プロセス | CPU はほぼ 0。メモリ整理の観点で古いものを閉じる |
| エアリアル拡張の常駐プロセス | 休眠(累積 CPU 0.26 秒で停止) | SIP により削除不可。動画も消えたため実害なし。完全に消すには OS の拡張機能管理が要る |
| watchman 未インストール | Metro のファイル監視が Node 自前経路 | 暴走の再現ができていないため未確認。再発したら導入を検討 |