Class: Chocomint::Console::ConPTY

Inherits:
Object
  • Object
show all
Defined in:
lib/chocomint/console/conpty.rb

Overview

Windows の擬似コンソール (ConPTY) を FFI で直接叩き、対話シェルを 本物の tty として起動するラッパー。xterm.js のバックエンドに使う。

重要な前提 (スパイク検証で判明): CreatePseudoConsole を呼ぶプロセスは 自身がコンソールにアタッチされている必要がある。コンソールを持たない プロセス (CREATE_NO_WINDOW で起動された等) から生成すると、初期 VT シーケンスは出るが子プロセスの出力が out パイプに流れてこない。 そのため本クラスを使うワーカーは必ずコンソール付きで起動すること (例: 非表示ウィンドウの新規コンソール)。詳細は console_worker を参照。

セキュリティ注意: 生の対話 tty は PathGuard / allowed_commands の監督外で 任意コマンドを実行できる。信頼できるローカル環境専用 (127.0.0.1 限定 + トークン認証) で使うこと。DESIGN §13 の shell 展開禁止原則の例外。

Defined Under Namespace

Modules: Native Classes: Error

Constant Summary collapse

DEFAULT_SHELL =

出力を UTF-8 に固定する (-NoExit で初期コマンド実行後もセッションを継続)。 これをしないと OutputEncoding が環境依存になり、日本語が化ける (âââ 等)。

%(pwsh.exe -NoProfile -NoLogo -NoExit ) +
%(-Command "[Console]::OutputEncoding=[Text.Encoding]::UTF8;) +
%($OutputEncoding=[Text.Encoding]::UTF8;chcp 65001 > $null;Clear-Host")
STILL_ACTIVE =
259

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(command: DEFAULT_SHELL, cols: 80, rows: 25, cwd: nil) ⇒ ConPTY

起動せずにインスタンスだけ作る。実際の起動は #open で行う。 command: 起動するシェル。cwd: 作業ディレクトリ (nil で継承)。



108
109
110
111
112
113
114
# File 'lib/chocomint/console/conpty.rb', line 108

def initialize(command: DEFAULT_SHELL, cols: 80, rows: 25, cwd: nil)
  @command = command
  @cols = cols
  @rows = rows
  @cwd = cwd
  @open = false
end

Instance Attribute Details

#colsObject (readonly)

Returns the value of attribute cols.



116
117
118
# File 'lib/chocomint/console/conpty.rb', line 116

def cols
  @cols
end

#rowsObject (readonly)

Returns the value of attribute rows.



116
117
118
# File 'lib/chocomint/console/conpty.rb', line 116

def rows
  @rows
end

Class Method Details

.pack_size(cols, rows) ⇒ Object

COORD を CreatePseudoConsole/ResizePseudoConsole 用の DWORD にパックする。



102
103
104
# File 'lib/chocomint/console/conpty.rb', line 102

def self.pack_size(cols, rows)
  (rows << 16) | cols
end

Instance Method Details

#alive?Boolean

子プロセスが生きているか。

Returns:

  • (Boolean)


186
187
188
189
190
191
192
193
# File 'lib/chocomint/console/conpty.rb', line 186

def alive?
  return false unless @open

  code = FFI::MemoryPointer.new(:uint32)
  return false if Native.GetExitCodeProcess(@proc_handle, code).zero?

  code.read(:uint32) == STILL_ACTIVE
end

#closeObject

擬似コンソールとプロセスを閉じ、ハンドルを解放する。冪等。



207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/chocomint/console/conpty.rb', line 207

def close
  return unless @open

  Native.TerminateProcess(@proc_handle, 0) if alive?
  Native.ClosePseudoConsole(@hpcon) if @hpcon
  Native.CloseHandle(@in_write) if @in_write
  Native.CloseHandle(@out_read) if @out_read
  Native.CloseHandle(@proc_handle) if @proc_handle
  Native.DeleteProcThreadAttributeList(@attr_list) if @attr_list
  @open = false
  nil
end

#exit_codeObject

子の終了コード (まだ生きているなら nil)。



196
197
198
199
200
201
202
203
204
# File 'lib/chocomint/console/conpty.rb', line 196

def exit_code
  return nil unless @open

  code = FFI::MemoryPointer.new(:uint32)
  return nil if Native.GetExitCodeProcess(@proc_handle, code).zero?

  value = code.read(:uint32)
  value == STILL_ACTIVE ? nil : value
end

#openObject

擬似コンソールを作りシェルを起動する。二重 open は禁止。

Raises:



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/chocomint/console/conpty.rb', line 123

def open
  raise Error, "already open" if @open

  create_pipes
  create_pseudo_console
  build_attribute_list
  spawn_process
  # 親側で不要なハンドルを閉じる。out_write を閉じないと EOF が来ない。
  Native.CloseHandle(@out_write)
  Native.CloseHandle(@in_read)
  @out_write = nil
  @in_read = nil
  @open = true
  self
end

#open?Boolean

Returns:

  • (Boolean)


118
119
120
# File 'lib/chocomint/console/conpty.rb', line 118

def open?
  @open
end

#read_available(max = 65_536) ⇒ Object

現在パイプに来ている出力をノンブロッキングで読む。無ければ "" を返す (バイナリエンコーディング; VT シーケンスを含む生ストリーム)。



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/chocomint/console/conpty.rb', line 156

def read_available(max = 65_536)
  ensure_open
  avail = FFI::MemoryPointer.new(:uint32)
  peek_ok = Native.PeekNamedPipe(@out_read, nil, 0, nil, avail, nil)
  # PeekNamedPipe 失敗はパイプが壊れた (子終了) とみなす。
  return nil if peek_ok.zero?

  n = avail.read(:uint32)
  return "".b if n.zero?

  want = [n, max].min
  buf = FFI::MemoryPointer.new(:uint8, want)
  read = FFI::MemoryPointer.new(:uint32)
  read_ok = Native.ReadFile(@out_read, buf, want, read, nil)
  return nil if read_ok.zero?

  buf.get_bytes(0, read.read(:uint32))
end

#resize(cols, rows) ⇒ Object

端末サイズを変更する (xterm.js のリサイズに追従)。

Raises:



176
177
178
179
180
181
182
183
# File 'lib/chocomint/console/conpty.rb', line 176

def resize(cols, rows)
  ensure_open
  hr = Native.ResizePseudoConsole(@hpcon, self.class.pack_size(cols, rows))
  raise Error, "ResizePseudoConsole failed (hr=0x#{hr.to_s(16)})" unless hr.zero?

  @cols = cols
  @rows = rows
end

#write(data) ⇒ Object

シェルへ書き込む (xterm.js のキー入力に相当)。バイト数を返す。

Raises:



140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/chocomint/console/conpty.rb', line 140

def write(data)
  ensure_open
  bytes = data.to_s.b
  return 0 if bytes.empty?

  buf = FFI::MemoryPointer.new(:uint8, bytes.bytesize)
  buf.put_bytes(0, bytes)
  written = FFI::MemoryPointer.new(:uint32)
  ok = Native.WriteFile(@in_write, buf, bytes.bytesize, written, nil)
  raise Error, "WriteFile failed (#{Native.GetLastError})" if ok.zero?

  written.read(:uint32)
end