RPCブリッジ:IPC通信とターミナルセッションのライフサイクル
前提知識
- ›第1回:アーキテクチャとプロジェクトナビゲーション
- ›Electron IPCの基礎(ipcMain、ipcRenderer)
- ›擬似端末(PTY)の基本的な理解
RPCブリッジ:IPC通信とターミナルセッションのライフサイクル
第1回で見たように、HyperのmainプロセスとrendererプロセスはElectronのIPCメカニズムで接続された別々の世界です。ただし、HyperはIPCをそのまま使うのではなく、型付きでUUIDスコープを持つRPCシステムとしてラップしています。このシステムは、ウィンドウごとのメッセージ分離、EventEmitterスタイルのAPI、そして2種類の異なる通信パターンを提供します。キー入力からターミナル出力まで、Hyperにおけるすべての重要なやり取りはこのブリッジを経由します。このRPCシステムの仕組みを理解することは不可欠です。
この記事では、まずRPCシステムを解剖し、次にターミナルセッションの完全なライフサイクルを追います。PTYがどのように生成され、出力がパフォーマンスのためにどのようにバッチ処理され、最終的にxterm.jsに届いてレンダリングされるまでの流れを見ていきましょう。
UUIDスコープのRPCチャンネル
Hyperの各BrowserWindowは、UUIDで識別される専用のIPCチャンネルを持ちます。これは重要な設計上の判断です。この仕組みがなければ、あるウィンドウのターミナルデータが別のウィンドウに漏れてしまう可能性があります。
Serverクラス(mainプロセス側)がUUIDを生成し、チャンネルを確立します。
BrowserWindowのロードが完了すると、サーバーはwebContents.send('init', uid)でUUIDをrendererに送信します。Clientクラス(renderer側)はこのUUIDを受け取り、そのチャンネルをサブスクライブします。
sequenceDiagram
participant M as Main (Server)
participant E as Electron IPC
participant R as Renderer (Client)
M->>M: Generate UUID (e.g., "a1b2c3...")
M->>E: ipcMain.on(uuid, listener)
Note over M: Window finishes loading
M->>R: webContents.send('init', uuid, profileName)
R->>R: Cache uuid in window.__rpcId
R->>E: ipcRenderer.on(uuid, listener)
R->>R: emitter.emit('ready')
Note over M,R: Channel established — all future messages use this UUID
R->>E: ipcRenderer.send(uuid, {ev: 'new', data: {...}})
E->>M: ipcMain receives on uuid channel
M->>R: webContents.send(uuid, {ch: 'session add', data: {...}})
Clientのコンストラクタにはうまいキャッシュ戦略が使われています。window.__rpcIdを設定しておくことで、ホットリロードなどによりClientオブジェクトが再インスタンス化された場合でも、initイベントを待たずにキャッシュ済みのUUIDを使って即座に再接続できます。
ServerとClientはどちらも、型ジェネリクスパラメータを持つEventEmitterインスタンスをラップしています。ServerはRendererEvents(main→renderer)を emit し、MainEvents(renderer→main)をリッスンします。Clientはその逆です。これにより、すべてのメッセージがコンパイル時に型チェックされ、rendererからmainプロセスのイベントを誤って emit してしまうようなミスを防げます。
2つのIPCパターン:イベント vs リクエスト/レスポンス
Hyperは用途に応じて、根本的に異なる2つのIPCパターンを使い分けています。
パターン1:Fire-and-Forgetイベント — ストリーミングデータやコマンドなど、送信側がレスポンスを必要としない場面で使われます。RPCのServer.emit()とClient.emit()がこのパターンを実装しています。ターミナル出力、セッションのライフサイクルイベント、UIコマンドはすべてこのイベント方式です。
パターン2:リクエスト/レスポンス(invoke/handle) — rendererがmainプロセスにデータを問い合わせる必要がある場面で使われます。ElectronのipcMain.handle() / ipcRenderer.invoke()ペアを利用し、Promiseを返します。
リクエスト/レスポンスパターンは、プラグイン関連のクエリ専用です。デコレートされたconfig、デコレートされたキーマップ、ロード済みプラグインのバージョン、ファイルシステムパスの取得がその対象です。これらはrendererがセットアップ中に必要とするデータですが、管理はmainプロセスが担っています。
flowchart LR
subgraph "Fire-and-Forget (RPC)"
A[Renderer] -->|"emit('new', options)"| B[Main]
B -->|"emit('session data', data)"| A
end
subgraph "Request-Response (invoke/handle)"
C[Renderer] -->|"invoke('getDecoratedConfig')"| D[Main]
D -->|"Promise<configOptions>"| C
end
ヒント: Hyperプラグインを書いていてmainプロセスからデータが必要な場合は、RPCイベントよりも
invokeパターン(ipcRenderer.invoke経由)を優先しましょう。リクエスト/レスポンスパターンはクリーンなPromiseベースのAPIを提供し、返信のためにイベントリスナーを管理する手間がかかりません。
型安全なイベント定義
typings/common.d.tsの型定義は、すべてのIPC通信における信頼の源泉です。3つの型がその契約を定義しています。
MainEvents(renderer → main)は15のイベントを定義します。
RendererEvents(main → renderer)は42のイベントを定義します。
IpcCommands(リクエスト/レスポンス)は8つのコマンドを定義します。
classDiagram
class MainEvents {
+close: never
+command: string
+data: uid+data+escaped
+exit: uid
+init: null
+new: sessionExtraOptions
+resize: uid+cols+rows
...15 events total
}
class RendererEvents {
+session add: Session
+session data: string
+session exit: uid
+termgroup add req: options
+split request: options
+move jump req: number
...42 events total
}
class IpcCommands {
+getDecoratedConfig() configOptions
+getDecoratedKeymaps() keymaps
+getLoadedPluginVersions() versions
+getPaths() paths
+child_process.exec() stdout+stderr
...8 commands total
}
98行目にあるFilterNever<T>ユーティリティ型は巧妙なパターンです。ペイロード型がneverのイベント(closeやmaximizeなど)は、emit時にデータ引数が不要になります。emitメソッドはオーバーロードされてこれを強制しており、rpc.emit('close')は有効な呼び出しですが、データ文字列なしでrpc.emit('session data')を呼ぶと型エラーになります。
PTYセッションの作成と環境セットアップ
新しいターミナルタブがリクエストされると、mainプロセスはSessionオブジェクトを生成します。このオブジェクトはnode-ptyの擬似端末をラップしています。
環境のセットアップは非常に丁寧に行われています。
- ベース環境は
process.envからクローンされ、Linux上ではAppImageのパスがクリーンアップされます。 - ターミナル変数が設定されます:
TERM=xterm-256color、COLORTERM=truecolor、TERM_PROGRAM=Hyper。 - ロケールは
os-localeで検出され、LANG=xx_XX.UTF-8として設定されます。 - Electronの環境汚染が除去されます:
GOOGLE_API_KEYはシェル環境に漏れ出さないよう削除されます。 - プラグインによるデコレーション:
decorateEnv拡張ポイントにより、プラグインが環境変数を追加・変更できます。
シェルのフォールバック機構(182〜218行目)も注目に値します。シェルが1秒以内にゼロ以外の終了コードで終了した場合、Hyperは設定が壊れていると判断し、デフォルトシェルにフォールバックします。これにより、シェルのパス設定を誤ったユーザーがターミナルを使えなくなる事態を防いでいます。
DataBatcherによるパフォーマンス最適化
ターミナルエミュレーターはPTYから毎秒数千もの小さなデータチャンクを受け取ることがあります。それらを1つずつIPCで送信するとパフォーマンスに壊滅的な影響を与えます。HyperのDataBatcherは、2つの閾値を用いたバッチ処理戦略でこの問題を解決しています。
flowchart TD
A[PTY emits data chunk] --> B{Batch size >= 200KB?}
B -->|Yes| C[Flush immediately]
B -->|No| D[Append to batch buffer]
D --> E{Timer running?}
E -->|No| F[Start 16ms timer]
E -->|Yes| G[Wait for timer]
F --> H[Timer fires → Flush]
G --> H
C --> I[Reset buffer to UID prefix]
H --> I
I --> J[Emit 'flush' → RPC sends to renderer]
16msのタイムアウトと200KBの上限という定数には明確な根拠があります。16msは60fpsのフレームバジェットに合わせており、rendererが1フレームにつき最大1バッチしか処理しないことを保証します。200KBの上限は、単一の巨大なバッチによるメモリ圧迫を防ぐためです。
最も巧妙な最適化がUIDプリペンド戦略です。各バッチはthis.data = this.uidで初期化され、バッファの先頭には36文字のUUIDが置かれます。rendererはこの文字列を受け取ると、d.slice(0, 36)でUIDを、d.slice(36)でデータを取り出します。バッチごとにラッパーオブジェクトを生成しなくて済むため、IPCペイロードを単一の文字列として扱えます。
ウィンドウが担うオーケストレーション
Hyperのすべてのサブシステムはapp/ui/window.tsに集約されます。newWindow関数はBrowserWindowを生成し、RPCサーバー、セッション管理、configサブスクリプション、プラグインフックを一まとめに接続します。
ウィンドウごとに2つの重要なリソースが生成されます。RPCのServerと、アクティブなターミナルセッションを追跡するMap<string, Session>です。
app/ui/window.ts#L122-L180のセッション作成フローでは、CWD(カレントワーキングディレクトリ)の引き継ぎが実際に行われているのを確認できます。preserveCWDが有効な場合、native-process-working-directoryを使ってアクティブセッションのPTYプロセスのワーキングディレクトリを解決し、新しいセッションの開始ディレクトリとして使用します。
sequenceDiagram
participant R as Renderer
participant RPC as RPC Channel
participant W as Window Manager
participant S as Session/PTY
R->>RPC: emit('new', {activeUid, profile})
RPC->>W: rpc.on('new') handler
W->>W: Resolve CWD from active session PID
W->>W: Get decorated session options from plugins
W->>S: new Session({uid, shell, cwd, ...})
S->>S: Spawn node-pty with environment
W->>RPC: emit('session add', {uid, shell, pid, ...})
RPC->>R: Renderer creates tab/pane
loop Terminal Output
S->>S: PTY data → DataBatcher.write()
S->>W: batcher 'flush' event
W->>RPC: emit('session data', uid+data)
RPC->>R: Dispatch SESSION_PTY_DATA
end
S->>W: PTY exit event
W->>RPC: emit('session exit', {uid})
W->>W: Clean up session from map
app/ui/window.ts#L359-L365のクリーンアップ関数は、リソースリークが起きないようにしています。RPCサーバーの破棄、すべてのセッションの終了、configとプラグインのサブスクリプション解除が行われます。
ヒント: 329〜330行目で公開されている
window.rpcとwindow.sessionsプロパティは、Hyperのプラグインシステムを支える裏口です。プラグインのonWindowフックはこれらのプロパティが付加されたBrowserWindowオブジェクトを受け取り、IPCレイヤーとセッション管理に直接アクセスできます。
次回のテーマ
mainプロセスとrendererプロセスの間でデータがどのように流れるかを追ってきました。では、ターミナルデータがrendererに届いた後はどうなるのでしょうか。そのデータはReduxに入っていきます。しかし、HyperのReduxセットアップは一筋縄ではいきません。次回の記事では、thunkが2度登場するミドルウェアチェーン、Reactを完全にバイパスするwriteミドルウェア、そしてスプリットペインをモデル化したイミュータブルなツリー構造を探っていきます。