数分おきに鳴るWindowsの謎の通知音、犯人はWSLだった

Windowsのスピーカーアイコンから伸びる矢印が、アプリケーション層・WSL/Linux層・認証トークン層の3層を貫いて原因にたどり着く様子を表したアイソメトリック図解 作ってみた
スポンサーリンク

🚀 忙しい人向けまとめ

問題: Windows PCから数分おきに通知音が鳴る。どのアプリが鳴らしているのか分からない

原因: WSL内のClaude Codeが5分ごとに起動され、その完了通知音がWSLg経由でWindowsに転送されていた。起動元はタスクバーの使用量モニター。トークンの更新に失敗し続けて、永久に再試行していた

解決法: Windows側でClaude Codeの認証を完了させる

ハマりどころ: 音量ミキサーには「リモート デスクトップ」と表示される。これがWSLの正体だと気づけなくて、真っ先に容疑者から外してしまいました

数分おきに鳴る、正体不明の音

ある日から、Windows PCが数分おきに通知音のようなものを鳴らすようになりました。何かの通知かと思って画面を見ても、何も出ていない。通知センターを開いても履歴は空っぽ。

音は鳴る。でも誰が鳴らしているのか分からない。地味にストレスの溜まる状況です。

こういうときの定番は、音量ミキサーを開いたまま待つ方法ですね。設定 > システム > サウンド > 音量ミキサー を開いておくと、音が鳴った瞬間に、鳴らしているアプリのレベルメーターが動きます。

やってみたら、一覧はこうなっていました。

Windows 11の音量ミキサーのアプリ一覧。システム音、Google Chrome、Steam、Steam Client WebHelper、Windows ターミナル、リモート デスクトップの6項目が表示されている
音が鳴った瞬間の音量ミキサーのアプリ一覧

システム音、Google Chrome、Steam、Steam Client WebHelper、Windows ターミナル。そして最後に「リモート デスクトップ」。

リモートデスクトップなんて使っていません。なので真っ先に除外しました。これが最初の間違いでした。

音源を自動で記録するスクリプトを書く

音が鳴るのは数分に一度。画面を睨み続けるのは現実的ではないので、監視スクリプトを書くことにしました。

Windowsのオーディオは WASAPI(Core Audio API)で扱えます。再生中の各セッションのピークレベルを0.1秒ごとに監視して、しきい値を超えた瞬間のプロセス名を記録する、という仕組みです。音量ミキサーの目視版を自動化したものだと思ってください。

やっていることは、詰まるところこれだけです。

foreach ($s in $sessions) {
    $peak = $s.GetPeak()          # セッションの現在のピークレベル
    if ($peak -gt $Threshold) {   # しきい値を超えたら記録する
        Write-Log ("▶ 音の発生   {0}" -f $s.Label)
    }
}

WASAPIを叩くためのCOMインターフェース宣言が長いので、全文は記事末尾にまとめました。

放置しておいたら、犯人が出ました。

2026-07-20 18:36:07  ▶ 音の発生   msrdc  (PID 24944)
                     出力先: スピーカー (High Definition Audio Device)
                     実行ファイル: C:\Program Files\WSL\msrdc.exe

C:\Program Files\WSL\msrdc.exe。WSLでした。

「リモート デスクトップ」の正体はWSLだった

ここで最初の除外ミスが判明します。

音量ミキサーに表示されるのは、アプリ名ではなく実行ファイルの説明文(FileDescription)です。msrdc.exe は Microsoft Remote Desktop Client なので、「リモート デスクトップ」と表示されます。

なぜWSLがリモートデスクトップなのか。WSLg(WSLのGUI・音声機能)は、RDPプロトコルを使ってLinux側の画面と音をWindowsに転送しているからです。仕組みとしてはリモートデスクトップそのものなんですね。

つまり、WSL内で鳴った音は、Windowsからは「リモート デスクトップ」として見える。これは検索してもまず結びつかないと思います。実際、音量ミキサーの一覧とスクリプトの検出結果を突き合わせたら、6個の項目がきれいに1対1で対応していました。

音量ミキサーの表示実体
システム音システム音
Google Chromechrome.exe
Steamsteam.exe
Steam Client WebHelpersteamwebhelper.exe
Windows ターミナルWindowsTerminal.exe
リモート デスクトップmsrdc.exe(WSLg)

犯人は最初から目の前に表示されていました。

WSLの中を追いかける

WSLgの音声は PulseAudio 経由で流れています。ということは、同じ発想でWSL側でも監視できます。再生ストリーム(sink-input)を0.2秒ごとにポーリングして、新しく出てきたものを記録するスクリプトを書きました。

結果はこうです。

▶ 音の発生   paplay
   ストリーム名: /home/user/.claude/assets/perfect.mp3
   コマンド    : paplay ~/.claude/assets/perfect.mp3
   親プロセス  : /bin/sh -c echo '通知: タスク完了' && paplay ...

Claude Codeの通知音でした。これは標準機能ではなく、僕が自分でhooksに仕込んだものです。タスクが終わったら音で知らせてほしくて、いつか設定しました。

つまり音そのものは自分で用意したものだった。問題は、自分が動かしていないClaude Codeがそれを鳴らしていることです。

誰がClaude Codeを起動しているのか

スクリプトを改良して、親プロセスをPID 1まで遡るようにしました。Linuxなら /proc/PID/statusPPid を読んで辿るだけなので簡単です。

while [ -n "$p" ] && [ "$p" != "0" ]; do
    p="$(awk '/^PPid:/{print $2}' "/proc/$p/status")"   # 親のPIDへ
    cmd="$(tr '\0' ' ' < "/proc/$p/cmdline")"           # 親のコマンドライン
    log "[$depth] PID $p  $cmd"
    [ "$p" = "1" ] && break
done

出てきた系譜がこれです。

[1] /bin/sh -c echo '通知: タスク完了' && paplay ...
[2] claude -p .
[3] bash -lic "if command -v claude ...; then claude -p .; ..."
[4] /init      ← WSLの入口
[5] /init
[6] /init (PID 1)

/init が親ということは、このbashはWSLの中ではなくWindows側から wsl.exe 経由で起動されているということです。WSLの中をいくら探しても見つからないはずでした。

そこでWindows側に戻って、今度は wsl.exe の起動を監視するスクリプトを書きます。Win32_Process を0.4秒ごとにポーリングして、起動を検出したらコマンドラインと親プロセスの系譜を記録する、という方針です。

▶ 起動検出   wsl.exe
   コマンド: "wsl.exe" -d Ubuntu -- bash -lic "... claude -p . ..."
   --- 親プロセスの系譜 ---
   [1] PID 51476  claude-code-usage-monitor.exe

犯人は、愛用していた便利ツールだった

Claude Code Usage Monitor(CodeZeno製、MIT)でした。

Claude Code Usage Monitorのgithub READMEページ。platform Windows、License MITのバッジと、タスクバーに表示される使用量ウィジェットのスクリーンショット、5時間ウィンドウ64%・7日ウィンドウ39%の表示
Claude Code Usage Monitor。タスクバーに5時間・7日それぞれの使用量を表示してくれる

Windowsのタスクバーに使用量を常時表示してくれるツールで、5時間ウィンドウと7日ウィンドウの残量、リセットまでのカウントダウンが一目で分かります。Claude Codeだけでなく Codex や Antigravity の使用量も並べて見られるので、かなり重宝しているやつです。

winget install CodeZeno.ClaudeCodeUsageMonitor

以前Linuxのsarコマンド風に使用量を眺めるツールを自分でも作ったことがあるんですが、常時表示の手軽さでこちらに乗り換えていました。まさか自分がいつも眺めているツールが犯人だとは思いませんでしたね。

なお以下の挙動は、執筆時点の v1.4.9 のものです。

なぜ鳴り続けたのか、原因は3つ重なっていた

1. トークン更新をClaude CLIに丸投げしている

オープンソースなのでソースを読みました。Rust製です。該当箇所にはコメントまで付いていました。

/// Invoke the Claude CLI with a minimal prompt to force its internal
/// OAuth token refresh.
fn cli_refresh_wsl_token(distro: &str) {
    // wsl.exe -d Ubuntu -- bash -lic "claude -p ."

このツールはOAuthトークンの更新機能を自前で持っていません。トークンが期限切れだったとき、Claude CLIに最小のプロンプト(.)を投げて、CLI内部の更新処理を起こさせるという方法を取っています。認証情報がWSL側にあったので、呼ばれるのはWSLのClaude Code。そして完了時に、僕が仕込んだ通知フックが鳴る、という流れでした。

ただ、これだけでは説明がつきません。更新が成功していれば、次のポーリングでは期限内なので呼ばれないはずです。5分ごとに毎回鳴るのはおかしい。

2. WSLのトークンは5か月前に期限切れだった

WSL側の認証情報を確認しました。

expiresAt = 2026-02-11 07:35   ← 5か月前

2月から期限切れのまま放置されていました。WSLでの作業はとっくにやめてWindowsに移行していたので、誰も触らないまま眠っていたわけです。

そしてモニター側のコードはこうなっていました。

loop {
    if !is_token_expired(creds.expires_at) { return Ok(creds); }
    cli_refresh_token(&source);
    match read_credentials_from_source(&source) {
        Some(refreshed) if !is_token_expired(...) => return Ok(refreshed),
        Some(_) => diagnose::log("still expired after refresh attempt"), // ← ログるだけ

「更新を試したが状態が変わらなかった」ことを記憶しません。 バックオフも試行回数の上限もないので、失敗し続ける限り永久に再試行します。

3. 引き金は、中途半端に終わった認証移行

とはいえ、これだけでもまだ音は鳴りません。モニターは Windows → WSL の順に認証情報を読むので、Windows側が有効なら1段目で完結してWSLには触らないからです。実際、モニターを導入してからしばらくは何も起きていませんでした。

引き金は別にありました。Claudeの認証をパスキーに移行しませんか、と促されて、途中で終わってしまったという出来事です。これでWindows側の認証情報が中途半端な状態になっていました。

accessToken = ""     ← 空
expiresAt   = 0      ← 0は「1970年」なので、永久に期限切れ判定

これで毎回1段目が失敗し、2段目のWSLに落ちるようになった。そこには2月から期限切れのトークンが待っていた、という流れです。

時系列にするとこうなります。

  1. 2月: WSLのトークンが期限切れに。もうWSLでは作業していないので放置(この時点では無害)
  2. その後: 使用量モニターを導入。Windowsの認証情報が有効なので正常に表示、無音
  3. 最近: パスキー移行を促され、途中で終了 → Windowsの認証情報が空に
  4. 毎回WSLにフェイルオーバーするようになり、音が鳴り始めた

壊れた認証情報が直列に2つ並んでいたわけです。片方だけ直しても止まりません。これが今回の分かりにくさの正体でした。

直し方

今はもうWSLで作業していないので、Windows側で認証を完了させることにしました。

claude
#   → /login → ブラウザ認証を最後まで完了させる

claude doctor
#   → "Not signed in to claude.ai" が消えることを確認

モニターは Windows → WSL の順に認証情報を読みます。Windows側が有効になれば、

if !is_token_expired(creds.expires_at) { return Ok(creds); }

ここで即リターンするので、WSLには一切触らなくなります。これで音は止まりました。

WSL側もついでに再認証しておきました。たまに使うかもしれませんし、期限切れのまま放置しておくと、また何かのフォールバック先として踏まれる可能性がありますからね。

使ったスクリプト

追跡に使った3本は、そのまま動く形でGistに置いてあります。いずれも管理者権限は不要で、ログをファイルに書きながら動きます。音が鳴るまで放置して、あとからログを見る使い方です。

スクリプト実行場所役割
audio-source-logger.ps1WindowsWASAPIで音を鳴らしたプロセスを記録
wsl-audio-source-logger.shWSLPulseAudioでWSL側の音源と親プロセスを記録
watch-wsl-launcher.ps1Windowswsl.exe の起動元を親プロセスまで遡って記録

Gist: Windowsの謎の音を追跡する3本のスクリプト

音の出どころが分からないとき、1本目だけでもかなり役に立つと思います。

今回学んだこと

音量ミキサーの表示名は、アプリ名ではなく実行ファイルの説明文です。「リモート デスクトップ」がWSLだとは、まず結びつきません。表示名で判断せず、実行ファイルのパスまで確認するのが確実ですね。

WSLの音は、Windows側からだけでは追いきれません。WASAPIとPulseAudio、2つのレイヤーを別々に追う必要があります。片方だけ見ていても永遠に見つかりません。

そして、使わなくなった環境の残骸は、半年後に別の形で牙をむきます。WSLからWindowsに移行したあと、期限切れの認証情報が5か月間ずっと眠っていました。それ単体では無害でしたが、フェイルオーバー先として参照された瞬間に問題になった。移行したつもりでも、前の環境は消えていないという感じです。

引き金になったのは、途中で終わったパスキー移行が残した「空の認証情報」でした。中途半端な状態はエラーとして表面化せず、静かにフォールバック経路を踏ませ続けます。認証の移行フローは最後まで完了させる。地味ですが、これが一番の教訓かもしれません。

モニター自体は今も使い続けています。むしろ、失敗し続ける再試行がたまたま音として表に出てきたから追跡できたわけで、通知音を仕込んでいなかったら気づかないまま裏で回り続けていたかと思います。謎の音に感謝、という感じですね。

コメント