電源を切ると直るバグを追う — ThinkPadキーボードのスクロールだけが死ぬ理由

開発未分類メモ

電源を切ると直るバグを追う — ThinkPadキーボードのスクロールだけが死ぬ理由

症状は、条件のほうが奇妙だった

Lenovo の外付けキーボード ThinkPad TrackPoint Keyboard II を使っていると、ときどき中ボタンを押しながら赤ポチを倒すスクロールが効かなくなる。キー入力は打てる。左クリックも右クリックもポインタ移動も正常。スクロールだけが死ぬ

発生条件として申告されたのは次の3つだった。

  • 席を離れて Windows が自動スリープし、電源ボタンで復帰させたときに起きやすい
  • Windows の「再起動」では直らないことがある
  • 電源をオフにしてから入れ直すと直る

最後の2つが噛み合わない。ふつう、再起動のほうが電源オフ→オンより「深い」初期化に見える。実際には逆で、再起動では USB ポートへの給電が維持され、完全な電源断ならデバイス側がリセットされる、という説明は成り立つ。だからこの時点での見立ては「USB デバイスかハブが、スリープ復帰時のリセットに失敗して変な状態で固まっている」だった。

結論から言うと、この見立ては外れだった。そして最終的な犯人は、USB でもドライバでもなかった。

調べる対象を先に押さえる

デバイスを列挙すると VID_17EF(Lenovo)PID_60EE2台分出てきた。左手用と右手用に、同じ型を2台つないでいるためだ。あとで「2台あることが競合の原因では」という疑いが出てくるので、この時点で頭に入れておく。

あわせて、効いてきそうな条件を先に並べておく。

項目
OSWindows 11 Pro 10.0.26200
キーボードThinkPad TrackPoint Keyboard II × 2台(USB接続)
接続経路Genesys Logic の USB ハブを2段カスケード
スリープS3 のみ(Modern Standby はファームウェア非対応)
休止状態・高速スタートアップ無効
USB セレクティブサスペンドAC 側で無効

外付けキーボードなので、疑う先は大きく2系統になる。USB の接続経路か、キーボードに付属する Lenovo のソフトか。まず前者から見た。

空振り その1 — 死んだUSBポートは死体ですらなかった

USB のツリーをたどると、それらしいものが見つかった。

USB Root Hub 3.0
└ Generic USB Hub (05E3:0610)          ← Genesys Logic 製
   └ Generic USB Hub (05E3:0610)       ← 2段目
      ├ port1: ThinkPad Keyboard II
      ├ port2: ThinkPad Keyboard II
      └ port3: Unknown USB Device (Port Reset Failed)   ← ?

キーボードと同じハブの隣のポートに、ポートリセットに失敗したデバイスがある。安価な USB ハブがスリープ復帰時のリセットに失敗するのはよくある話で、筋書きとしては悪くない。

詳細を見て、この線は消えた。

Problem            : CM_PROB_PHANTOM
ProblemDescription : Currently, this hardware device is not connected to the computer. (Code 45).
Present            : False

CM_PROB_PHANTOM(コード45)は「今は接続されていない」という意味で、デバイスマネージャーで「非表示のデバイスを表示」にしたときだけ見える幽霊エントリだ。過去に挿していた何かの登録が残っているだけで、現在の不具合とは関係がない。

USB の電源管理も見たが、セレクティブサスペンドは AC 側で無効になっていた。デスクトップなので常時 AC であり、実効しない。

空振り1つ目。 ただし後述するとおり、この2つの否定は後で外部レビューに突き返されることになる。

手がかりは「壊れ方の非対称性」だった

行き詰まったので、症状そのものを見直した。

キー入力は生きている。左右クリックも生きている。スクロールだけが死ぬ。

これは強い手がかりだ。もしキーボードのUSB接続そのものが壊れているなら、キー入力も一緒に死ぬはずで、そうなっていない。つまりキーボードから Windows までの標準的な経路は無事で、スクロールだけが別の仕組みに依存していることになる。

そこで、その「別の仕組み」がどこにあるかを探した。まずカーネル空間を疑う。

Win32_PnPSignedDriver に DriverProviderName = Lenovo のもの → 0 件

HIDClass    UpperFilters: (空)
Mouse       UpperFilters: mouclass    ← Windows標準のみ
Keyboard    UpperFilters: kbdclass    ← Windows標準のみ

Lenovo のカーネルドライバは1本も入っていなかった。 マウスやキーボードのクラスに独自のフィルタドライバを挟んでもいない。すべて Windows 標準のままだ。

ここで方向が定まる。カーネルに手が入っていないのなら、中ボタンスクロールはユーザーモードの常駐プログラムが、独自の HID インターフェースを読んでホイール操作を合成しているしかない。

そして、その構造なら壊れ方の非対称性が説明できる。キー入力と左右クリックは OS 標準の HID 経路をそのまま流れるので無傷。常駐プログラムに依存するスクロールだけが、単独で落ちる。

空振り その2 — 二世代のドライバが相打ちしている説

インストール済みソフトを見ると、思わぬものが出てきた。

ソフト名バージョンサービス
ThinkPad TrackPoint Keyboard II Software1.0.8.06241ExternalTPKBSvc
ThinkPad Compact Keyboard with TrackPoint driver1.5.6.0ThinkPadKBSvc

第2世代用と第1世代用のドライバソフトが両方入っていて、両方のサービスが同時に走っていた。 つないでいるキーボードは2台とも第2世代なので、第1世代用は本来不要だ。

さらにインストール先を覗くと、両方のフォルダに同名の実行ファイルが揃っている

HScrollFun.exe   ← HScroll = スクロール機能の実体
MainCpl.exe      ← 設定パネル/トレイ常駐
osd.exe          ← 画面表示(Caps Lock 等)
SetSpeed.exe     ← ポインタ速度

そしてスタートアップ登録が、2世代分そろって並んでいた。aHScrollutility(第1世代)と anHScrollutility(第2世代)という具合に、接頭辞だけ違う同じ顔ぶれが二重に登録されている。

ここで「同じ名前・同じ機能のプロセスが2セット同時に起動しようとして、ミューテックスの取り合いで相打ちになるレースコンディションだ」と考えた。起動タイミングで勝敗が変わるなら、「電源オフ→オンだと直る」という確率的な挙動も説明がつく。

もっともらしい。しかしこれも外れだった

全滅していた

症状が出ている最中に、常駐プロセスの生死を数えた。ここが分岐点になった。

プロセスセッション状態
ExternalTPKBSvc0動いている
ThinkPadKBSvc0動いている
logonsetsvc0動いている
logonset1動いている
HScrollFunいない
MainCpl / osd / SetSpeedいない

スクロールの実体である HScrollFun.exe が、そもそも起動していなかった。

レースコンディションどころではない。相打ちする以前に、どちらも土俵に上がっていなかった。

理由はレジストリにあった。Windows はスタートアップの有効・無効を StartupApproved というキーで管理していて、先頭バイトが 2 なら有効、3 なら無効を意味する。

anHScrollutility  3   ← 第2世代スクロール:無効
anRunMaincpl      3      anOSD  3      anSetSpeed  3
aHScrollutility   3   ← 第1世代スクロール:無効
aRunMaincpl       3      aOSD   3      aSetSpeed   3

8本すべてが無効。あとで 32bit 側のビュー(WOW6432Node)にも旧世代の3本が登録されていて、そちらも同じく無効だと分かった。合計11本、全滅である。

おそらく過去のどこかで、起動を軽くする目的でタスクマネージャーのスタートアップタブからまとめて無効化されたのだろう。誰がいつ切ったかまでは追えなかった。

壊れていたときの流れを追うと、こうなる。

改善前。スリープから復帰してもスタートアップ登録が無効のため、スクロールを合成する常駐 HScrollFun.exe が起動されず、プロセス不在のままになる流れ
図1: 復帰後にスクロールだけが死ぬ経路。起動を担うはずのスタートアップ登録が無効で、常駐が一度も立ち上がらない

キー入力と左右クリックはこの図に登場しない。それらはキーボードから Windows へまっすぐ流れるので、右端の常駐が不在でも何の影響も受けない。この図に描かれている経路だけが、スクロールの生死を握っている。

犯人を1本起動して確定させる

仮説が正しいなら、その1本を手で起動すれば直るはずだ。削除でもインストールでもなく、落とせば元に戻る可逆な操作でもある。

Start-Process "C:\Program Files (x86)\Lenovo\External TrackPoint Keyboard driver\HScrollFun.exe"

3秒後も生きていた(PID 36644, セッション1)。すぐ落ちるわけではない。

そして実機で確認してもらったところ、中ボタンスクロールが復活した

HScrollFun.exe の不在が直接原因である、と確定した。

外部レビューで、否定した仮説が3つ突き返される

ここで OpenAI の Codex(gpt-5.6-sol)に調査記録を渡し、「やったこと・やっていないこと・仮説」をレビューしてもらった。自分で2回も仮説を外している以上、第三者の目を入れる価値がある。

返ってきた指摘は容赦がなかった。

1つめ。「スタートアップが無効だから二世代競合は否定できる」は成立しない。 StartupApproved が止めるのは Explorer 経由の起動だけで、サービスや独自ランチャーからの起動までは止めない。実際、Codex が独自に調べたところ次の混在が見つかった。

  • サービスとして登録されている logonsetsvc の実体は第1世代側のフォルダにある
  • しかし HKLM\Software\ServiceAppPath第2世代側logonset.exe を指している
  • 両世代が HKLM\Software\Lenovo\autolaunch という同じキーを共有している

自分でも確認したところ、そのとおりだった。autolaunch に書かれた installed_version1.5.6.0、つまり第1世代のバージョンで上書きされている。「旧世代のサービスが新世代の実行ファイルを起動する」というねじれた状態になっていた。

2つめ。「プロセスが生きているか」を見るだけでは自己復旧の判定として足りない。 手動起動で直ったという事実は、次の2つのどちらでも説明できてしまう。

  • 常駐プログラムが動いている間じゅう、ずっとスクロールを合成している
  • 起動した瞬間にデバイスと結び直しただけで、あとは別の何かが担っている

もし後者なら、プロセスは生きているのに機能だけ死んでいる状態がありえて、生存確認では救えない。

3つめ。否定の根拠が弱いものが他にもある。

  • 「2台とも同じ世代だから競合しない」— 単一インスタンスのユーティリティが最初の1台だけを掴む実装は普通にありうる
  • 「セレクティブサスペンドが無効だからUSB電源管理は無関係」— 否定できるのは動作中のアイドルサスペンドだけで、S3スリープへの遷移に伴うデバイス電源状態の変化は別の話。これは明確にこちらの誤りだった
  • 「幽霊デバイスだから無関係」— 現在いないことしか示さない。過去の復帰時にリセット失敗が起き、それが引き金になった可能性までは消せない

決定打は、人間の目だった

指摘の2つめは対策の有効性に直結する。ところがこれは、意図せず解決した。

検証のために HScrollFun.exe を一度落とし、タスクで復旧させる実験をしていたとき、画面の前にいた本人からこう報告が来た。

「落ちました」と打った時、確かにスクロールができてたのができなくなった。「タスクを発火させます」と言ったその後に、ターミナルが一瞬ちらついてスクロールできるようになった

プロセスを落とした瞬間にスクロールが死に、起こし直した瞬間に生き返った。

これで前者が確定する。HScrollFun.exe は常駐している間ずっとスクロールを合成しており、プロセスの生存がそのまま機能の生存を意味する。ログや API では取れなかった因果が、目の前の人間の観察一発で埋まった。

なお、このプロセスは通常権限からは終了できない。

Stop-Process : アクセスが拒否されました

入力を扱うソフトとして高い整合性レベル(uiAccess)で動いているためで、落とすには昇格が必要だった。副作用として、実行ファイルのパスも通常権限では読み取れない。

対策 — 落ちていたら起こし直す

原因が「起動していないこと」なので、対策は「確実に起動させること」になる。スタートアップを有効に戻す手もあるが、それだけでは復帰中に落ちた場合を拾えない。そこでタスクスケジューラに見張り役を置いた。

  • ログオン時(20秒遅延)
  • スリープ復帰時Microsoft-Windows-Power-Troubleshooter のイベントID 1 を購読(15秒遅延)
  • ロック解除時(5秒遅延)

動作は「同じセッション内に HScrollFun がいなければ起動する」。いれば何もしないので二重起動しない。遅延を入れているのは、復帰直後は USB の再列挙が終わっていない可能性があるためだ。管理者権限は不要で、外部ファイルも置いていない。

図1で誰もいなかった真ん中の位置に、見張りが入る。

改善後。復帰イベントを合図に見張りタスクが起動し、同一セッション内で常駐の生存を確認して、不在のときだけ HScrollFun.exe を起こし直す流れ
図2: 同じ位置に見張りタスクを置いた後。復帰を合図に生死を確かめ、落ちていたときだけ起こし直す

2枚を見比べると、変えたのは1点だけだと分かる。起動の合図を「ログオン時に1回」から「復帰・ログオン・ロック解除のたび」に増やし、その合図を受け取る役を常駐の手前に置いた。 HScrollFun.exe 自身にも、Lenovo のドライバにも手を入れていない。

Codex の指摘を受けて直した点が2つある。多重起動ポリシーを IgnoreNew から Parallel に変えた(前者だと実行中に届いた復帰トリガーが捨てられる)。それと、次に症状が出たとき「プロセスがいたのか、いなかったのか」を証拠で言えるよう、実行結果を %LOCALAPPDATA%\ThinkPadScrollWatchdog\watchdog.log に追記するようにした。

「登録できた」で終わらせない

タスクを登録した直後、LastTaskResult: 0 が返ってきた。これは「タスクが正常終了した」以上のことを何も意味しない。復旧したかどうかは別の話だ。

そこで実際に壊して試した。最初の試行はこう出た。

BEFORE PID : 36644
AFTER KILL : 1 process      ← 落ちていない
AFTER TASK : 36644          ← 同じPIDのまま

Stop-Process が失敗していたのに、-ErrorAction SilentlyContinue でエラーを握り潰していた。検証手順そのものが壊れていたわけで、危うく「動きました」と報告するところだった。

昇格して落とし直し、あらためて通した結果がこれになる。

AFTER KILL count : 0        ← 確かに落ちた(この間スクロールは死んでいた)
REVIVED PID : 38376         ← 別のPIDで復活

そして在中時にタスクを走らせると、二重起動せずログにこう残る。

2026-08-11 13:13:35 sid=1 ALIVE pid=38376

不在なら起こす、在中なら手を出さない。両側とも実機で確認できた。

残っている謎

正直に書くと、当初の症状すべてを説明しきれてはいない

最大の穴は「電源オフ→オンなら直る」という体験だ。スタートアップが11本とも無効なら、コールドブートでも HScrollFun.exe は起動しないはずで、辻褄が合わない。

別の起動経路を探して、サービスのバイナリを文字列走査した。

バイナリHScrollFunCreateProcessAsUserWTSGetActiveConsoleSessionId
ExternalTPKBSvc.exe無し無し無し
logonsetsvc.exe無し有り有り

logonsetsvc.exe は「サービスからユーザーセッションにプロセスを起こす」ときの定番の組み合わせを持っている。起こす能力はある。ただし起動対象の名前が文字列として現れないため、これが HScrollFun.exe を起こしているという確証は取れなかった。

もう一つ、スリープ復帰との因果も実は未検証のままだ。今回症状を見つけたのは再起動から約10分後であって、スリープ復帰の直後ではない。つまり今回観測したのは「復帰でクラッシュした状態」ではなく「最初から起動していない状態」だった可能性が高い。両者を切り分けるには、実際にスリープ復帰させて観測するしかない。ログを残すようにしたのはそのためでもある。

この件から持ち帰れること

  • 申告された条件が矛盾していたら、そこが手がかりになる。 「再起動で直らないのに電源オフで直る」という噛み合わなさが、最初に疑う対象を決めてくれた。結果として外れたが、外れ方が次の一手を教えてくれた
  • 壊れ方の非対称性を見る。 全部が死ぬのか、一部だけが死ぬのか。今回は「キーは効くのにスクロールだけ死ぬ」が、カーネルではなくユーザーモードを見ろという道標になった
  • もっともらしい仮説ほど、証拠で潰す。 死んだUSBポートも二世代競合も、話としてはよくできていた。前者は Present: False の一行で、後者はプロセス一覧で消えた
  • LastTaskResult: 0 は「直った」ではない。 壊して直ることを見るまで、対策が効いた証拠は何もない
  • 外部レビューは、自分の否定を疑うために使う。 今回 Codex が刺したのは新しい発見ではなく、こちらが「無関係」と切り捨てた根拠の甘さだった。実際1つは明確な誤りだった

調査記録の一次資料は memo/2026-08-11/thinkpad-trackpoint-scroll-investigation.md に置いてある。