🤖

Windowsで子プロセスと端末の話

に公開
4

前々回はCランタイムのロケールの話をし、前回の話ではWindowsの端末入出力がクライアントからどう制御されるかを粗方説明しました。今回はそれらの知見を踏まえ、子プロセスを起動する話をしようと思います。さらに、子プロセスとして起動されるプロセスが、端末かどうかで動作が変わる場合も考慮します。

今まで説明した仕組みを参考にすれば、文字化けの原因究明や解決は可能なのですが、文字化けの仕組みが分からない場合の現象切り分けや、自動テストなどの際に多少役に立つかも程度の話になります。

1. Windowsで子プロセスを起動するには…

今回起動させるプロセスはこんなシェルスクリプトを想定しています。

https://github.com/marudedameo2019/terminal_and_child_process/blob/main/child.sh

1~100まで1行ずつ出力したのをページャーで見るだけのものです。

1-1. system()使用

一番簡単なのはCランタイムのsystem()関数を使う方法です。

https://github.com/marudedameo2019/terminal_and_child_process/blob/main/call_system.cpp

Windowsのシェルであるcmdpoertshellexplorerなどからも起動するので、shibang(#!/bin/sh)は機能しません。なので、shを起動して-cオプションで実際のコマンドラインを記述しています。二重引用符で括られた"PATH=$PATH:/usr/bin ./child.sh"がシェルが解釈するコマンドライン文字列になります。最初のPATH=$PATH:/usr/binという部分は環境変数PATHを次の./child.shを実行するときだけ変更する、という書き方です。PATH環境変数には/usr/binが追加されています。

sh.exe起動時には原則それまでのWindowsのPATH環境変数を引き継いで(中には部分的に削られるものもある)いて、mountはされているものの、/usr/binすらPATHに入っていないからです。これが入っていないと、child.sh内で使用されているseqlessコマンドが見つかりません。

なので、やや込み入った文字列になっていますが、やっていることはchild.shの実行だけなのです。

実際に実行すると、コマンドライン文字列が一旦表示されたあと、普通にchild.shを実行したときと同じ動きになります。system()関数はパイプなどを使わず、同期実行となるので、一度起動するとユーザーが子プロセスを終了させるまで何もできません。仮にやりたいことが自動実行だった場合は、それが出来ないということです。

1-2. CreateProcess()API使用

自動実行するにはパイプが必要だということで、今度はCreateProcessを使ってみます。

https://github.com/marudedameo2019/terminal_and_child_process/blob/main/call_createprocess.cpp

実行してみると分かるのですが、ページャーのlessがページングしてくれません。これはlessが端末実行でないと判断しているためです。前回端末かどうかの判定方法を説明しましたが、lessは主にstdoutがキャラクタデバイスかどうかを見て端末かどうかを判断しています。今回stdoutはパイプなのでキャラクタデバイス判定されず、端末と判定されず、ページングされなかったということです。しかし自動実行したり確認したいのはページングの部分なのです。これはどう実現すればいいのでしょう?

1-3. 疑似端末の使用

CreatePseudoConsole()APIを使用します。正確には疑似コンソールと呼んでるものです。端末をホストするアプリを作成し、その上でコンソールアプリを起動させるというものです。ホスト側はコンソールアプリが出力したエスケープシーケンスなどを処理して、正しくコンソール本体などに反映させないといけません。とはいえ、コンソール本体にエンコーディングだけ合わせてそのまま流し込めば大丈夫です。

https://github.com/marudedameo2019/terminal_and_child_process/blob/main/call_createpseudoconsole.cpp

今回は待ち合わせのため、使用するエスケープシーケンス(MSの用語ではコンソール仮想ターミナル シーケンス)を読み飛ばせるようにだけ実装を入れたら、少々長くなってしまいました。待ち合わせと言ってるのはlessが出力している、画面下の:です(実際にはもう1つ、(END)があります)。出力する文字列の末尾が:だったら、次ページを要求する空白を送ったりしています(ほかにも待ち合わせ文字列があるのですが、これは別のシェルスクリプト用です)。

待ち合わせ成功時はすぐに送ると目視確認に困るので、1秒だけディレイを入れています。

大雑把な処理は以下のとおりです。

メインスレッド

  1. コンソールモードの設定(疑似コンソール用の必須設定だけ入れる)
  2. ホスト側(コチラ)とクライアント側(lessとか起動する方)をつなぐパイプを2つ用意
  3. 疑似コンソールの作成
  4. 通信用のスレッド作成
  5. 子プロセス用の起動時スレッド属性を用意(疑似コンソール属性が必要)
  6. 子プロセス起動
  7. 子プロセス終了待ち
  8. 各種リソースの破棄

通信用スレッド

  1. 子プロセスの端末出力が繋がっているパイプから読み込み
  2. エスケープシーケンスを読み飛ばしながら、行末の特定文字列を探す
  3. 見つかったら1秒待って子プロセスの端末入力が繋がっているパイプに qを書き込む
  4. 読み込めなくなるまで読み込んだ内容を標準出力(本物の端末)に垂れ流す

※待ち合わせ処理は正直かなりアバウトなので、実用には堪えません
※デッドロックの可能性も多分あります
※端末の状態によっては表示が壊れて制御できなくなる場合があります

2. まとめ

子プロセスがパイプと端末で処理を変える場合は、(ちょっと大変だけど)疑似コンソールを使うと子プロセスに端末と思わせながら操作することができる

おまけ

scriptコマンドもどきを付けときました。
こちらはエスケープシーケンス解析がないので、より簡単かもしれません。画面出力を記録して、後から再生できます。

https://github.com/marudedameo2019/terminal_and_child_process/blob/main/trecwin.cpp

記録

./build/trecwin [実行ファイル]

デフォルトの実行ファイルはpowershellです。実行ファイルが実行されたあと、戻ってくると記録完了になります。
カレントディレクトリにtypescriptというファイルとfile.tmというファイルを作ります。

なお、このコマンドはscriptコマンドで以下のようにした処理を真似ています。

script -T file.tm

再生

./build/trecwin --play

カレントディレクトリのtypescriptというファイルとfile.tmを元に記録した表示を再生します。画面表示の記録が再生されてるだけなので、記録したときの処理が再実行されてるわけではありません。

なお、これらのファイルはMSYS2環境などにあるscriptreplayでも再生出来ます。

scriptreplay -T file.tm
GitHubで編集を提案

Discussion

GoldSmithGoldSmith

疑似コンソールについて書かれた日本語の記事を初めてみました。
私は、ただ、C++プログラムからSSHコマンドを打ちたかっただけなんですが、謎のエラーが出たので疑似コンソールという物を使うのではと、作っていました。エスケープシーケンスとかあるので、それらの対処は仮想ターミナルウィンドウにまかせ、インターセプトで独自のコマンドを流していました。

dameyodamedamedameyodamedame

私は疑似コンソールについては手探り状態ですし、今もよく分かってませんよ。

sshについては確かパスワード入力時に端末入力を要求される気がしました。大昔の話なのでうろ覚えだし、今もそうなるのかは分かりませんけど。そこさえクリアしてしまえば(パスフレーズなしの公開鍵認証などで)、後はパイプでほとんどの操作を出来るし、それこそ繋いだ先のスクリプトでどうにかしていた感じです。

今回の私のコード(call_createpseudoconsole.cpp)の方では、特定文字列の待ち合わせ(インターセプト?)を正確にするために、エスケープシーケンスを読み飛ばす感じに実装しており、ホストアプリ側の実際の端末処理は標準出力に任せていました(仮想ターミナルウィンドウに任せているという感じです)。

おっしゃりたいことがイマイチ分からないのですが、

  1. 謎のエラーの原因を知りたい
  2. エスケープシーケンスを読み飛ばす理由を知りたい
  3. 雑談的なコメントをしたかっただけ
  4. 何かそれ以外の話

どういうご用件なのでしょうか?

GoldSmithGoldSmith

1,謎のエラー:もううろ覚えですが、CreateProcessでsshを実行しようとするとエラーが出ます。エラーコードも出ます。
2,全く同じことをやっていました。
3,はい、仮想コンソールの記事は当時、web検索にかけても、Windows APIをそのままコピーした物しか引っかかりませんでした。GitHubもものすごくマニアックなリポジトリ、及びC#で使う為の定義みたいなものしか見つかりませんでした。CoPilotはまだ世に公開されていませんでした。
ただ、雑談したかっただけです。
お気を悪くされたなら、申し訳ありませんでした。
以後、からみませんので。どうか今回の件は納めて頂けるとありがたいです。

dameyodamedamedameyodamedame

4択を出したつもりだったのですが、3だったということですね。単に返事をしようにもコメントの意図が分からなかったので、まず聞いたというだけの話です。雑談ならそう書いてもらえると嬉しいですね。そもそもここではそういう発言をしてる記事をあまり見かけないので。

もううろ覚えですが、CreateProcessでsshを実行しようとするとエラーが出ます。エラーコードも出ます。

sshはセキュリティ的に特別な部分があるわけでないと思うので、CreateProcessが失敗するという意味なら、かなり特殊な事情ではないかと…。

#include <windows.h>

int main() {
    STARTUPINFOA si;
    ZeroMemory(&si, sizeof(STARTUPINFOA));
    PROCESS_INFORMATION pi;
    ZeroMemory(&pi, sizeof(PROCESS_INFORMATION));

    char cmd[] = "ssh --help";
    auto h = CreateProcessA(NULL, cmd, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi);
    WaitForSingleObject(pi.hProcess, 5000);
}

こんな簡単なコードでも普通にヘルプは表示されます。あまり気にされていないようなので、お返事は不要ですが。

3,はい、仮想コンソールの記事は当時、web検索にかけても、Windows APIをそのままコピーした物しか引っかかりませんでした。GitHubもものすごくマニアックなリポジトリ、及びC#で使う為の定義みたいなものしか見つかりませんでした。CoPilotはまだ世に公開されていませんでした。

Windows Terminalなどが一般に出てきたのは最近(数年前)だし、コンソールAPIの部分の記述が新しくなったのもそれなりに最近(数年前)ではあると思います。CoPilotが何のCoPilotか分からないので、これもまたいつの話かハッキリせず、何かが明快になる感じではありませんね。私が20年くらい前から使ってる方法(公開鍵認証で鯖側スクリプト使用)は前回のコメントのとおりなので、何をしたくて困っていたのか個人的にはイマイチ分かりませんが、あまり気にされていないようなので、説明は不要です。