Go でも疑似端末が簡単に！(Windowsも含め)

# 疑似端末とは

正確には「仮想コンソール、端末装置、シリアルポートハードウェアなどを使用しないテキスト端末」[^wikipe]という定義になるようですが、ここでは「実際にはユーザの目に触れる端末へ直接接続されているわけじゃないんだけど、そうだとプロセスに思い込ませてその入出力を捕らえる仕組み」とでもお考えください。

# 何に使うの？リダイレクトではダメなのか？

UNIX/Linux には `script` というコマンドがあります。これはコマンドの標準出力・標準エラー出力をすべてひっくるめてファイルに記録してくれるので、間違いが許されないような作業をするときのエビデンスを残すのに重宝します。でも、これ `コマンド名 2>&1 | tee ファイル名` でも良さそうな気もしますが、それではダメなんでしょうか？

結論としてはインタラクティブなやりとりが発生するコマンドではうまくいきません。たとえば、プロンプトが適切なタイミングで表示されず、目隠し状態で入力した後に遅れてプロンプトが表示されるといった問題が発生します。

こうなる理由として、ほとんどのプログラムは標準出力・標準エラーに対して

- 出力先が端末などのデバイスであれば、ただちに出力する
- さもなければ、I/O効率化のため、すぐ出力せずにバッファリングし、改行・一定量データがたまる等のタイミングで一括して出力する

という挙動をするからです。そのため、`コマンド 2>&1 | tee ファイル名` のようなパイプラインの場合、端末でないと認識されて、バッファリングが働きます。

そこで次のように、普通の端末と同じような出力の体裁を作ってやります。

- アプリ　──(標準出力)─▶　疑似端末　──(読み出し)─▶　出力内容をあれこれするプログラム 

そうすれば、たいていのアプリケーションはコマンドプロンプトや普通のターミナル上と同じ挙動を示してくれるわけです。また、逆もありですね

- アプリ　◀─(標準入力)──　疑似端末　◀─(書き出し)──　入力内容を用意してやるプログラム

*※ このあたりは自分なりの解釈なので、本来の定義からすると、前後関係が逆だとかいろいろご指摘ありそうな気もしますが、まぁ、理解するための方便ということで*

# Windows でも疑似端末が使えるように

Windows10 で Windows Subsystem for Linux：WSL が実装されました。それにともなって、WSL内 Linux の出力を Windows のコマンドプロンプト画面で引き受けるようなケースが出てきたからか、Windows のコンソールに多くの改修が施されました。

- ANSIエスケープシーケンスのサポート[^ansi]（ただし、[SetConsoleMode] という API で有効にする手続きが必要）
    - 制御キーも `ESC[A`（↑キー） といった入力シーケンスで受信可能に(Cooked/Raw モードもサポート）
- 疑似コンソールのサポート[^pseudo-console]
- 絵文字などサロゲートペアが必要な Unicode 出力のサポートと、それへの対応が難しい 一部の API の deprecate化[^deprecated]

つまり、物理的には「やればできる」環境が整ったわけです。

# 疑似端末をサポートする Go のパッケージもよいものがそろってきた

- [qsocket/conpty-go: Windows Pseudo Console (ConPTY) implementation for Golang.](https://github.com/qsocket/conpty-go)
- [aymanbagabas/go-pty: Cross platform Go Pty interface][go-pty]

[go-pty]: https://github.com/aymanbagabas/go-pty

基本的には、いずれも

- 疑似端末を作成し、それに標準入出力を紐づけた子プロセスを起動する
- 疑似端末に出力された内容を io.Reader で読み出せる
- 疑似端末から入力した体裁にしたい内容を io.Writer で書きだす

という機能を提供しています。特に後者の [go-pty] は

- 一つの疑似端末から複数の子プロセスを起動可能
- Windows の ConPty、UNIX の pty をラッピングして、OSを問わないクロスプラットフォームを実現

など、扱いやすいものとなっています。

# 実装例

- [hymkor/script: script.exe - make typescript of terminal session like that of Linux for Windows10 or later](https://github.com/hymkor/script)
- [hymkor/lispect: A text-terminal automation tool similar to expect(1) using the subset of ISLisp](https://github.com/hymkor/lispect)

UNIX/Linux の script コマンド / expect コマンドを [go-pty] の力を借りて作ってみたものです。Windows でも、Linux 同様の操作記録や自動運転が出来るようになったは、ちょっとした感動がありました。

ただ、すごく簡単に出来たかと言うと、若干、癖？のようなものも散見され、少々の苦労がありました。

#### 疑似端末からの読み出しをモタモタしていると、疑似端末への書き出しがつまってしまう？

物理端末の os.Stdin の内容は、そのまま疑似端末の入力に放り込んでいたのですが、それがある修正をしてからなぜか凍るようになってしまいました（キーを入力しても反応しなくなる）

```go
import (
    //
	"github.com/aymanbagabas/go-pty"
)

ptmx, err := pty.New()
go io.Copy(ptmx, os.Stdin)
```

いろいろ試してみたところ、上のコードとは直接関係ない箇所：端末からの出力の読み出しが加工などでモタモタしていたのを、次のように簡素（Channelに丸投げするだけ）にすると、凍る現象が解消されました。
（それにしても、なぜ、出力側の読み出しが滞ると、入力側への書き出しがブロックされるのやら…）

```go
func NewWatcher(ptmx pty.Pty) *Watcher {
	pipeline := make(chan string, 1024)
	go io.Copy(ptmx, os.Stdin)
	go func() {
		for {
			var buffer [1024]byte
			n, err := ptmx.Read(buffer[:])
			if err != nil {
				close(pipeline)
				return
			}
			// If the code below spends a lot of time,
			// It hangs up to io.Copy(ptmx, os.Stdin)
			// The reason is unknown.
			os.Stdout.Write(buffer[:n])
			pipeline <- string(buffer[:n])
		}
	}()
	return &Watcher{ch: pipeline}
}
```

#### Windows だけ疑似端末立ち上げ時に画面がクリアされてしまう

画面をクリアする `ESC[2J` がしっかりと出力されていましたが、これが出るのは Windows だけのようです。試しに `ESC[2J` をフィルタリングしてみたところ… CMD.EXE を子プロセスで呼び出した時、まるで画面をクリアされた後に実行されることを想定したようなカーソル位置決め打ちシーケンスが CMD.EXE によって出力されており、余計に画面が乱れるという結果なってしまいました。

go-pty のソースをざっと見たところでは該当する処理に対応するコードは見つからなかったので、より深い階層のライブラリの仕様か、[CreatePseudoConsole]の仕様なのかもしれません[^4]。

[^4]: [CreatePseudoConsole]の第４引数にゼロが渡されてるので、これを PSEUDOCONSOLE_INHERIT_CURSOR (DWORD)1 に変えたら挙動が変わってくるのかもしれませんが…

```
^[[2J^[[m^[[HMicrosoft Windows [Version 10.0.22000.2538]]0;C:\Windows\system32\cmd.exe^[[?25h^[[?25l
(c) Microsoft Corporation. All rights reserved.^[[4;1H[1] <C:\Users\hymkor>
$ ^[[?25hexit
```

これについては下手に手を入れても改悪になりかねないので、もう画面クリアは仕様だと受け入れました。

# まとめ

少し不明な点はあるものの、自分のようなホビーレベルの技術愛好家でも手軽に疑似端末が使えるようになったのは喜ばしいことです。今後、[ttyrec] のようなツールも、どなたかの手によって Windows 向けに登場するのかもしれません[^screentogif]

[^wikipe]: [擬似端末 - Wikipedia](https://ja.wikipedia.org/wiki/%E6%93%AC%E4%BC%BC%E7%AB%AF%E6%9C%AB)
[^ansi]: [コンソールの仮想ターミナル シーケンス - Windows Console | Microsoft Learn](https://learn.microsoft.com/ja-jp/windows/console/console-virtual-terminal-sequences)
[^pseudo-console]: [疑似コンソール セッションの作成 - Windows Console | Microsoft Learn](https://learn.microsoft.com/ja-jp/windows/console/creating-a-pseudoconsole-session)
[^deprecated]: 具体的には [ReadConsoleOutput](https://learn.microsoft.com/ja-jp/windows/console/readconsoleoutput)などコンソールを直接読み取る API。１セルあたりに割り当てている文字コードサイズを 16bit 固定なので、サロゲートペアな文字が物理的に入らない
[^screentogif]: 自分は ScreenToGif とかで十分なので、そこまでするモチベが…

[SetConsoleMode]: https://learn.microsoft.com/ja-jp/windows/console/setconsolemode
[ttyrec]: http://0xcc.net/unimag/4/
[CreatePseudoConsole]: https://learn.microsoft.com/en-us/windows/console/createpseudoconsole