Read OSS

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を生成し、チャンネルを確立します。

app/rpc.ts#L16-L36

BrowserWindowのロードが完了すると、サーバーはwebContents.send('init', uid)でUUIDをrendererに送信します。Clientクラス(renderer側)はこのUUIDを受け取り、そのチャンネルをサブスクライブします。

lib/utils/rpc.ts#L20-L42

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を返します。

app/plugins.ts#L467-L480

リクエスト/レスポンスパターンは、プラグイン関連のクエリ専用です。デコレートされた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のイベントを定義します。

typings/common.d.ts#L32-L48

RendererEvents(main → renderer)は42のイベントを定義します。

typings/common.d.ts#L50-L93

IpcCommands(リクエスト/レスポンス)は8つのコマンドを定義します。

typings/common.d.ts#L112-L128

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のイベント(closemaximizeなど)は、emit時にデータ引数が不要になります。emitメソッドはオーバーロードされてこれを強制しており、rpc.emit('close')は有効な呼び出しですが、データ文字列なしでrpc.emit('session data')を呼ぶと型エラーになります。

PTYセッションの作成と環境セットアップ

新しいターミナルタブがリクエストされると、mainプロセスはSessionオブジェクトを生成します。このオブジェクトはnode-ptyの擬似端末をラップしています。

app/session.ts#L113-L168

環境のセットアップは非常に丁寧に行われています。

  1. ベース環境process.envからクローンされ、Linux上ではAppImageのパスがクリーンアップされます。
  2. ターミナル変数が設定されます:TERM=xterm-256colorCOLORTERM=truecolorTERM_PROGRAM=Hyper
  3. ロケールos-localeで検出され、LANG=xx_XX.UTF-8として設定されます。
  4. Electronの環境汚染が除去されます:GOOGLE_API_KEYはシェル環境に漏れ出さないよう削除されます。
  5. プラグインによるデコレーションdecorateEnv拡張ポイントにより、プラグインが環境変数を追加・変更できます。

シェルのフォールバック機構(182〜218行目)も注目に値します。シェルが1秒以内にゼロ以外の終了コードで終了した場合、Hyperは設定が壊れていると判断し、デフォルトシェルにフォールバックします。これにより、シェルのパス設定を誤ったユーザーがターミナルを使えなくなる事態を防いでいます。

DataBatcherによるパフォーマンス最適化

ターミナルエミュレーターはPTYから毎秒数千もの小さなデータチャンクを受け取ることがあります。それらを1つずつIPCで送信するとパフォーマンスに壊滅的な影響を与えます。HyperのDataBatcherは、2つの閾値を用いたバッチ処理戦略でこの問題を解決しています。

app/session.ts#L43-L85

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サブスクリプション、プラグインフックを一まとめに接続します。

app/ui/window.ts#L69-L70

ウィンドウごとに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.rpcwindow.sessionsプロパティは、Hyperのプラグインシステムを支える裏口です。プラグインのonWindowフックはこれらのプロパティが付加されたBrowserWindowオブジェクトを受け取り、IPCレイヤーとセッション管理に直接アクセスできます。

次回のテーマ

mainプロセスとrendererプロセスの間でデータがどのように流れるかを追ってきました。では、ターミナルデータがrendererに届いた後はどうなるのでしょうか。そのデータはReduxに入っていきます。しかし、HyperのReduxセットアップは一筋縄ではいきません。次回の記事では、thunkが2度登場するミドルウェアチェーン、Reactを完全にバイパスするwriteミドルウェア、そしてスプリットペインをモデル化したイミュータブルなツリー構造を探っていきます。