自動ログイン編・後編で、証券アプリへの毎朝2FAログインを UI Automation(要素ベース) で自動化した。検証は通った。本番VPSにも載せた。これで無人運用の完成——のはずだった。
ところが、妙な症状が出た。Macから RDP で繋ぎっぱなしにしていた翌朝は成功するのに、RDP を切って寝た翌朝は失敗する。サーバーは動いている。バックグラウンドのプロセスも cron 相当のタスクも生きている。GUI 自動化だけが落ちる。
「無人で回す」ための自動化なのに、「人が RDP で繋いでいないと動かない」。本末転倒だ。今回はこれを潰した記録。
TL;DR
- UI Automation は「対話デスクトップが描画されている」ことが前提。RDP を切ると描画面が消える。
- RDP 切断でセッションは
Disconnectedになり、仮想ディスプレイが破棄される → UIA が要素を掴めない。 - 解決は2段構え。①
fSingleSessionPerUser=1(セッションを分裂させない) + ② 切断イベント(EventID 24)でtscon <ID> /dest:consoleを自動実行。 tsconはプロセスを一切止めない(端末の付け替えだけ)。無停止で console に戻せる。- 実装で一番溶かしたのは仕組みではなく、PowerShell 5.1 の BOM 問題と SYSTEM 実行時の
query sessionの挙動。
症状:RDP を切ると翌朝こける
毎朝 6:35 に証券アプリへ自動ログインするタスクスケジューラを組んでいる。中身は前回作った uiautomation のスクリプトだ。
| 前夜の状態 | 翌朝6:35の結果 |
|---|---|
| Mac から RDP 接続したまま | 成功 |
| RDP を切断して寝た | 失敗 |
| サーバー再起動直後(誰も繋いでいない) | 成功 |
3行目が混乱の元だった。「誰も繋いでいなくても成功する場合がある」ので、しばらく原因を RDP だと思えなかった。
原因:Disconnected セッションには「描画面」が無い
Windows のセッションには状態がある。query session で見えるやつだ。
セッション名 ユーザー名 ID 状態
services 0 Disc
console 1 Active
rdp-tcp#3 user 2 Active
ここが核心。
- RDP 接続中:そのセッションは
Active。仮想ディスプレイが割り当てられ、デスクトップが描画されている。 - RDP 切断後:セッションは
Disconnectedに落ちる。プロセスは生きたままだが、描画面(仮想ディスプレイ)は破棄される。
UI Automation は「今そこに描かれている UI ツリー」を辿る仕組みなので、描画面が無いセッションでは要素を掴めない。バックグラウンド処理が平気なのは、そもそも描画を必要としないから。GUI 自動化だけが落ちるのはこのためだった。
つまり正確な条件は「Mac が繋がっていないと失敗」ではなく、
対話デスクトップが描画(active)されていないと失敗
だった。Mac の RDP 接続は、たまたまその条件を満たしていただけだ。
「再起動直後だけ成功する」の正体
AutoAdminLogon=1(自動ログオン)を有効にしていると、再起動直後はコンソールセッション(ID 1)が Active な状態で立ち上がる。だからこのときは成功する。
ところが、そこへ一度 RDP で入って切断すると、セッションは Disconnected に落ちたまま戻ってこない。**「再起動直後は動く/一度 RDP を触ると壊れる」**という、いちばん切り分けを難しくする挙動が生まれる。
解決策① セッションを分裂させない
まず土台。レジストリで単一セッション制限を入れる。
Set-ItemProperty `
-Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server' `
-Name 'fSingleSessionPerUser' -Value 1 -Type DWord
これで、同じユーザーが RDP で入ってきたとき、新しいセッションを作らずに既存のセッション(console)を引き取る。セッションが2つに分裂しないので、後で「どっちを console に戻すのか」で迷わなくて済む。
query session で見ると、RDP 接続中も ID は 1 のまま(名前だけ rdp-tcp#N になる)。
解決策② 切断イベントで console に自動で付け替える
本命はこっち。RDP を切断したら、そのセッションを物理コンソールに付け替える。コンソールは常に active なので、描画面が維持される。
使うのは tscon。
tscon 1 /dest:console
tscon はセッションの「端末」を付け替えるだけで、中で動いているプロセスは一切止めない。証券アプリも自作システムも起動したまま、描画先だけが console に移る。
これを手で叩くのではなく、切断イベントで自動発火させる。
トリガーにするイベント
| 項目 | 値 |
|---|---|
| ログ名 | Microsoft-Windows-TerminalServices-LocalSessionManager/Operational |
| EventID | 24(セッション切断) |
参考までに 25 は再接続。今回は 24 だけ拾えばいい。
タスクスケジューラの XML サブスクリプションはこう:
<QueryList>
<Query Id="0" Path="Microsoft-Windows-TerminalServices-LocalSessionManager/Operational">
<Select Path="Microsoft-Windows-TerminalServices-LocalSessionManager/Operational">
*[System[(EventID=24)]]
</Select>
</Query>
</QueryList>
タスク側の設定は 実行アカウント=SYSTEM / RunLevel=Highest / MultipleInstances=IgnoreNew。tscon で他人のセッションを触るには SYSTEM 権限が要る。
実際に切断テストした結果
20:36:18 tscon 1 /dest:console (state=Disc)
20:36:22 after:
>services 0 Disc
console <user> 1 Active ← 切断後も console が Active で維持
RDP を切った4秒後に console へ付け替わり、Active が維持されている。翌朝の自動ログインも通るようになった。
ここからが本番:スクリプトが SYSTEM で動かない
仕組み自体は上で完結している。実際に時間を溶かしたのは、**「手元では動くスクリプトが、SYSTEM 権限+日本語環境でだけ動かない」**という三重苦のほうだった。ここが今回いちばん共有する価値がある部分だと思う。
罠① PowerShell 5.1 が BOM 無し UTF-8 を Shift-JIS として読む
症状:タスクから powershell.exe -File tscon-console.ps1 を実行すると、こう落ちる。
Unexpected token '}' in expression or statement
該当行を見に行くと、そこには } が1文字あるだけ。文法的におかしいところが無い。
原因:スクリプトに日本語コメントを書いていて、ファイルが BOM 無し UTF-8 で保存されていた。Windows PowerShell 5.1 は、BOM の無いファイルをシステム ANSI(日本語環境なら Shift-JIS)として読む。全角文字のバイト列が壊れ、そこから先のパースが連鎖的に崩れて、無関係な行でエラーになる。
対策:UTF-8 with BOM で保存する。
# BOM 付きで書き出す
$utf8bom = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText($path, $content, $utf8bom)
エディタでは正しく見えるのに実行だけ失敗する——そういうときは文字コードを疑う。PowerShell 7 なら既定 UTF-8 なので起きないが、Windows Server 標準の 5.1 では現役の罠。
罠② query session はヘッダだけ日本語・状態値は英語
症状:出力をパースする実装が、SYSTEM で実行したときだけ「ヘッダ解析失敗」で全滅。手で叩くと通る。
原因:query session の出力ヘッダは実行アカウントのロケールで変わる。SYSTEM+日本語環境だと:
セッション名 ユーザー名 ID 状態
対話ユーザーで手打ちしたときは英語ヘッダ(SESSIONNAME / USERNAME / STATE)だったので気づかない。$header.IndexOf('STATE') のような実装が -1 を返して死ぬ。
そして意地が悪いのが、ヘッダは日本語になるのに状態値は英語のまま(Active / Disc / Conn / Listen)という点。
対策:ヘッダ文字列に依存せず、固定列オフセットでスライスする。状態値は英語なので判定はそのまま使える。
# 値行のみ対象。USERNAME は 26 桁目〜、44 桁目以降を空白分割して [ID, STATE]
$user = $line.Substring(26, 18).Trim()
$tail = $line.Substring(44).Trim() -split '\s+'
$id = [int]$tail[0]
$state = $tail[1] # Active / Disc / Conn / Listen(英語)
native コマンドの出力は実行アカウントのロケールで変わる。手元で動いても SYSTEM で壊れる。
罠③ query.exe は成功しても exit code 1 を返す
症状:(query session) 2>$null と書いていたら、SYSTEM 実行時に**出力が空(0行)**になる。
原因:query.exe は正常時でも LASTEXITCODE=1 を返すことがある。これが $ErrorActionPreference の設定やリダイレクトと噛み合うと、出力そのものが欠落する。
対策:ProcessStartInfo で stdout を直接読む。
function Get-QuerySession {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = Join-Path $env:SystemRoot 'System32\query.exe'
$psi.Arguments = 'session'
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$p = [System.Diagnostics.Process]::Start($psi)
$out = $p.StandardOutput.ReadToEnd()
$p.StandardError.ReadToEnd() | Out-Null
$p.WaitForExit()
return $out
}
終了コードが当てにならない native コマンドから確実に出力を取りたいときは、
ProcessStartInfoが堅い。
事故らない書き方
tscon はセッションを触るコマンドなので、対象の選び方を間違えると事故る。とくに tscon 0(services セッション)は絶対にやってはいけない。
# 対象ユーザーの Disconnected セッションだけを選ぶ。
# services(ID 0) / console / Listen は除外。console が既に Active なら何もしない。
$cand = $sessions |
Where-Object { $_.User -eq $targetUser -and
$_.Id -gt 0 -and
$_.Name -ne 'console' -and
$_.Name -ne 'services' -and
$_.State -match '^Disc' } |
Sort-Object Id | Select-Object -First 1
if ($cand) { tscon $cand.Id /dest:console }
守っているルールは4つ。
- 対象は「対象ユーザー名を持つ」「STATE=Disc」のセッションだけ
services(ID 0)を絶対に tscon しない- console が既に Active なら何もしない(切断イベントで無限に走らせない)
- 全処理でプロセスを止めない(
tsconは端末の付け替えのみ)
保険:VNC を console 配信で常駐させておく
tscon で console に付け替えるということは、裏を返せば「RDP で入り直すとまた剥がれる」ということでもある。RDP が何らかの理由で使えないときの非常口として、VNC を1本用意しておくと安心だ。
ポイントは配信対象をコンソールセッションにすること。こうしておくと、VNC ビューアを閉じてもデスクトップは active のままになる(RDP と違って描画面が消えない)。
ただし VNC を生でインターネットに晒すのは論外。ここは HTTPS化編 と同じ思想で、
- VNC サーバーは localhost バインド限定(ポートを外に開けない)
- アクセスは認証付きトンネル経由(Cloudflare Access のゼロトラスト認証を前段に置く)
という構成にしている。「非常口を作る」と「穴を開ける」は別物なので、ここは手を抜かない。
学び
- GUI 自動化(UIA / RPA / pyautogui)は「描画されている対話デスクトップ」が前提。RDP 切断で描画面は消える。
- 「サーバーは動いているのに GUI 自動化だけ落ちる」なら、まず
query sessionでセッションの状態を見る。 tscon <ID> /dest:consoleはプロセスを止めずに描画面を取り戻せる。切断イベント(EventID 24)で自動化すれば恒久対策になる。- PowerShell 5.1 + 日本語コメントは BOM 必須。
- native コマンドの出力はロケールと実行アカウントで変わる。SYSTEM で動かす前提のスクリプトは、SYSTEM で必ずテストする。
これでようやく、RDP を切っても、サーバーを再起動しても、人が触らずに毎朝の自動ログインが通る状態になった。アプリ運用編で「次の機会に」と書いた常駐化が、ここでひととおり片付いたことになる。
シリーズ:
- VPS構築編
- アプリ運用編
- HTTPS化編
- 自動ログイン編・検証VM
- 自動ログイン編・後編(uiautomationへ作り直し)
- 常駐化編(この記事)