以下の内容はhttps://syu-m-5151.hatenablog.com/entry/2026/01/29/092003より取得しました。


ZellijのRust実装パターン徹底解説(後編)

はじめに

前編を書き終えたあと、エディタを閉じて、しばらくターミナルを眺めていた。Zellijのペインが3つ並んでいる。左でVimが開き、右上でテストが走り、右下にシェルが待機している。何も起きていない。何も起きていないのに、裏では6つのスレッドが動いている。チャネルを介してメッセージが流れ、PTYがカーネルとやり取りし、VTEパーサがバイト列を解釈している。

前編では設計パターンを抽出した。cat huge_log_file.logで200万行を流し込んだとき、Zellijが固まらない理由——境界付きチャネルによるバックプレッシャー。その仕組みを概念として説明した。後編では、その実装の中に入る。

syu-m-5151.hatenablog.com

正直に言うと、後編は地味だ。WASMプラグイン通信プロトコルANSIエスケープシーケンスのパース、KDL形式のセッション永続化。どれも「知っていると便利」だが「知らなくても困らない」話かもしれない。華やかさはない。ただ、Rustで本格的なアプリケーションを書こうとしたとき、こういう地味な部分でつまずく。つまずいてから調べるか、先に知っておくか。その違いは、たぶん小さくない。

Cargo Workspace構成の深掘り

前編でCargo Workspaceの構造を見た。後編では、なぜこの分割になっているのかを考える。

zellij-utils/を開くと、IPCの定義やエラー処理、設定ファイルのパーサーが入っている。

default-plugins/* → zellij-tile → zellij-tile-utils
client ↓ server
       ↓
    zellij-utils ← 共有型定義(IPC契約)

zellij-utilsが双方向依存を防いでいる。clientもserverもutilsに依存するが、utilsはどちらにも依存しない。これにより、clientを変更してもserverの再コンパイルは不要になる。10万行超のコードベースでは、このビルド時間の差が開発体験に直結する。

zellij-tileSDKとして独立させた意図も見える。プラグイン開発者はサーバー実装への依存なしにビルドできる。これは「プラグインエコシステムの成長」を設計段階で意識した判断だ。後からSDKを切り出すより、最初から分けておく方が遥かに低コストになる。

後編で必要な追加知識

前編でPTY、チャネル、Actorモデル、WASMの基礎を説明した。後編では、さらに低レベルな概念が登場する。ここで整理しておこう。

termios構造体とターミナルモード

Unixのターミナルはtermios構造体で制御される。この構造体には、ターミナルの振る舞いを決めるフラグが数十個含まれている。

// nixクレートでの操作
let mut tio = termios::tcgetattr(fd)?;  // 現在の設定を取得
termios::cfmakeraw(&mut tio);            // Raw Modeに設定
termios::tcsetattr(fd, SetArg::TCSANOW, &tio)?;  // 即座に適用

ターミナルには2つの主要なモードがある。

Cooked Mode(カノニカルモード) - カーネルが行編集を処理する(バックスペース、Ctrl+Wなど) - Enterを押すまで入力がバッファされる - Ctrl+CでSIGINTが自動送信される

Raw Mode - すべてのキー入力がそのままアプリケーションに届く - 行編集もシグナル生成もアプリケーションの責任 - ターミナルマルチプレクサには必須

Zellijは起動時にRaw Modeに入り、終了時に元のモードに戻す。これを忘れると、ターミナルが「壊れた」状態になる。

主要なシグナル

ターミナルアプリケーションが扱う主なシグナルは以下の通り。

シグナル 発生条件 用途
SIGWINCH ウィンドウリサイズ ターミナルサイズの再取得
SIGINT Ctrl+C プロセスの中断
SIGTSTP Ctrl+Z プロセスの一時停止
SIGTERM killコマンド 正常終了の要求
SIGKILL kill -9 強制終了(捕捉不可)
SIGHUP 端末切断 セッション終了
SIGCHLD 子プロセス終了 子プロセスの状態変化

ZellijはSIGWINCHを特に注意深く扱う。ウィンドウをドラッグでリサイズすると、1秒間に数十〜数百回のSIGWINCHが発生する。すべてに反応するとパフォーマンスが悪化するため、スロットリング(間引き)が必要だ。

ioctl:デバイス制御

ioctl(I/O Control)は、デバイスに対する特殊な操作を行うシステムコールだ。ターミナル関連では以下が重要。

// ウィンドウサイズの取得
ioctl(fd, TIOCGWINSZ, &mut winsize);  // Get WINdow SiZe

// ウィンドウサイズの設定
ioctl(fd, TIOCSWINSZ, &winsize);      // Set WINdow SiZe

Winsize構造体は4つのフィールドを持つ。

struct Winsize {
    ws_row: u16,      // 行数
    ws_col: u16,      // 列数
    ws_xpixel: u16,   // ピクセル幅(Sixel画像用)
    ws_ypixel: u16,   // ピクセル高さ(Sixel画像用)
}

TIOCSWINSZでPTYのサイズを変更すると、カーネルは子プロセスにSIGWINCHを送る。シェルはこのシグナルを受けて画面を再描画する。

ANSIエスケープシーケンスの詳細

前編で触れたが、後編ではより詳しく見る。

CSI(Control Sequence Introducer)

\x1b[  → CSI開始

CSIの後にパラメータとコマンドが続く。

\x1b[31m      → 前景色を赤に(SGR: Select Graphic Rendition)
\x1b[10;5H   → カーソルを10行5列に移動(CUP: Cursor Position)
\x1b[2J      → 画面全体をクリア(ED: Erase in Display)
\x1b[?25h    → カーソルを表示(DECTCEM)
\x1b[?25l    → カーソルを非表示

OSC(Operating System Command)

\x1b]0;title\x07  → ウィンドウタイトルを設定
\x1b]8;;URL\x07   → ハイパーリンク開始

DCS(Device Control String)

\x1bP...ST  → 同期出力、Sixel画像など

Zellijはvteクレートでこれらをパースし、Performトレイトの各メソッドに振り分ける。

ファイルディスクリプタとPTY

Unixでは「すべてがファイル」だ。PTYもファイルディスクリプタ(FD)で表現される。

let OpenptyResult { master, slave } = openpty(None, &termios)?;
// master: RawFd (例: 3)
// slave:  RawFd (例: 4)

子プロセス(シェル)は、login_tty()で以下の処理を行う。

  1. 新しいセッションを作成(setsid()
  2. slave PTYを制御端末に設定
  3. FD 0, 1, 2(stdin, stdout, stderr)をslave PTYに接続

これにより、シェルの入出力はすべてPTY経由になる。

tcdrain:出力の完了待ち

tcdrainは、書き込んだデータがすべて送信されるまでブロックするシステムコールだ。

write(fd, bytes)?;   // バッファに書き込む
tcdrain(fd)?;        // 送信完了を待つ

なぜ必要か。write()カーネルのバッファに書き込んだ時点で返る。相手がまだ読んでいない可能性がある。tcdrain()を呼ぶと、バッファが空になるまで待機する。

Zellijでは、PTYへの書き込み後にtcdrain()を呼ぶ。これにより、入力が確実にシェルに届いてから次の処理に進む。

これらの概念の関係

[termios] ─── Raw/Cooked Mode を制御
    ↓
[PTY Master] ←── ioctl(TIOCGWINSZ/TIOCSWINSZ) でサイズ制御
    │
    │ write() + tcdrain()
    ↓
[PTY Slave] ─── シェルの stdin/stdout/stderr
    │
    │ SIGWINCH, SIGCHLD など
    ↓
[シグナルハンドラ] ─── スロットリング、グレースフル終了

後編では、これらの概念がZellijの実装にどう現れるかを見ていく。

境界付きチャネルの実装

前編で「バッファサイズ50」と書いた。実際のコードを見てみよう。

zellij-client/src/lib.rsを開く。

// zellij-client/src/lib.rs
let (send_client_instructions, receive_client_instructions): ChannelWithContext<
    ClientInstruction,
> = channels::bounded(50);  // バッファサイズ: 50

サーバー側も同様だ。zellij-server/src/lib.rsを開く。

// zellij-server/src/lib.rs
let (to_server, server_receiver): ChannelWithContext<ServerInstruction> =
    channels::bounded(50);

なぜ50なのか。開発者ブログやissue #525を調査したが、この値を選んだ明確な理由は記載されていなかった。ただし、技術的な背景は理解できる。

境界付きチャネル導入の発端はissue #525だ。PTYスレッドがプログラム出力を読み取り、無制限チャネル経由でScreenスレッドに送る。出力生成がレンダリング速度を超えると、キューが無限に成長し、メモリ使用量と入力遅延が悪化する。

単純に境界付きチャネルに変えるとデッドロックのリスクがある。PTYがキューを満杯にする→WASMスレッドがレンダリング命令を送ろうとしてブロック→Screenスレッド(キューを空にすべき側)がWASMスレッドの応答待ちでブロック——この連鎖だ。

解決策として、crossbeamのselect!マクロを使った選択的ルーティングが採用された。PTY→Screen間のみ境界付きチャネルでバックプレッシャーをかけ、他のコンポーネント間は無制限チャネルを維持する。50という数字は「小さすぎてスループットを落とさず、大きすぎてメモリを圧迫しない」経験的なバランス点だろう。

開発者ブログによると、境界付きチャネルの導入だけでベンチマークは19秒から9秒に改善された。

WASMランタイムの移行

Zellijのコードを読んでいて、最も意外だったのがこの部分かもしれない。WASMランタイムを「遅い方」に移行している。普通は逆だ。

前編では「wasmiを使っている」と書いた。しかし、Zellijの歴史を調べると、WASMランタイムは2度移行している。

初期はWasmer、0.40.0でWasmtime、そして最新版ではWasmiに移行した(PR #4449)。

zellij-server/Cargo.tomlを開く。

# zellij-server/Cargo.toml
[dependencies.wasmi]
version = "0.51.3"
default-features = false
features = ["std"]

なぜWasmtimeからWasmiへ移行したのか。PR #4449のディスカッションを読むと、理由が明確になる。

コンパイルからインタプリタ。Wasmtimeは.wasmファイルをJITコンパイルする。これには秒単位の時間がかかっていた。Wasmiはインタプリタ方式で、ミリ秒単位(一桁)で実行を開始できる。

キャッシュ管理の排除。Wasmtime時代はコンパイル済みコードをキャッシュしていた。プラグイン開発時に「キャッシュバスティング」が必要で、これがコードの複雑さを増していた。Wasmiならキャッシュ不要だ。

バイナリサイズとメモリ削減。WasmtimeはCraneliftコンパイラを含むため、バイナリサイズが大きい。Wasmiは純粋なRust実装のインタプリタで、依存関係がシンプルだ。Debianパッケージングでも、Wasmtimeの依存関係(wiggle等)がブロッカーになる可能性があったが、Wasmiなら問題ない。

性能面のトレードオフ。PRテスターの報告によると、Debugビルドでは一部プラグイン(Zjstatus)の起動に約1秒の遅延が観察された。ただし、Releaseビルドでは顕著な影響がなかった。コンパイルプロファイルの調整で改善も報告されている。

ステータスバーの更新に1msかかるか0.1msかかるか——ユーザーには分からない。「最速のランタイム」より「最もメンテナンスしやすいランタイム」を選んだ判断は、オープンソースプロジェクトとして合理的だ。

Protocol Buffersによるプラグイン通信

WASMランタイムがプラグインの「実行環境」なら、Protocol Buffersはプラグインの「通信手段」だ。プラグインとホスト間の通信はProtocol Buffersで実現されている。

zellij-utils/src/plugin_api/plugin_command.protoを開く。

// zellij-utils/src/plugin_api/plugin_command.proto
enum CommandName {
  Subscribe = 0;
  Unsubscribe = 1;
  SetSelectable = 2;
  GetPluginIds = 3;
  OpenFile = 9;
  OpenTerminal = 14;
  // ... 150以上のコマンド
}

150以上のコマンド。前編で見たScreenInstructionの100バリアントを超えている。プラグインはUI操作、ファイル操作、ネットワーク、他プラグインとの通信など、ホストの機能に広くアクセスできるからだ。

なぜProtocol Buffersなのか。WASMとホストの間でデータを受け渡すには、シリアライズが必要だ。JSONでもMessagePackでも良いが、Protocol Buffersには以下の利点がある。

  1. スキーマがドキュメントになる: .protoファイルを見れば、プラグインAPIの全体像が分かる
  2. 後方互換: フィールドの追加・削除が安全にできる
  3. 型安全: コード生成により、シリアライズ/デシリアライズのミスを防げる

zellij-tile/src/lib.rsのマクロを見ると、Protocol Buffersがどう使われているか分かる。

#[no_mangle]
fn load() {
    STATE.with(|state| {
        let protobuf_bytes: Vec<u8> = $crate::shim::object_from_stdin().unwrap();
        // Protocol Buffersをデシリアライズして設定を取得
    });
}

プラグインは標準入力からProtocol Buffersを読み、標準出力に書き込む。WASM境界を越えるのは単なるバイト列だ。シンプルだが、型安全性は失われない。

パーミッションシステムの設計思想

プラグイン16種類のパーミッションから必要なものを要求する。

zellij-utils/src/plugin_api/plugin_permission.protoを開く。

// zellij-utils/src/plugin_api/plugin_permission.proto
enum PermissionType {
  ReadApplicationState = 0;      // ペイン・タブ・UI状態の読み取り
  ChangeApplicationState = 1;    // ペイン・タブ・UIの変更
  OpenFiles = 2;                 // ファイルを開く
  RunCommands = 3;               // コマンド実行
  OpenTerminalsOrPlugins = 4;    // ターミナル/プラグインを開く
  WriteToStdin = 5;              // ペインへの入力
  WebAccess = 6;                 // HTTPリクエスト
  ReadCliPipes = 7;              // CLIパイプの読み取り
  MessageAndLaunchOtherPlugins = 8;  // 他プラグインとの通信
  Reconfigure = 9;               // 設定変更
  FullHdAccess = 10;             // ファイルシステム完全アクセス
  StartWebServer = 11;           // Webサーバー起動
  InterceptInput = 12;           // 入力のインターセプト
  ReadPaneContents = 13;         // ペイン内容の読み取り
  RunActionsAsUser = 14;         // ユーザーとしてアクション実行
  WriteToClipboard = 15;         // クリップボードへの書き込み
}

このパーミッションモデルは「悪意あるプラグイン」より「バグのあるプラグイン」を想定している——と私は読んだ。

考えてみてほしい。悪意あるプラグインを防ぎたいなら、ユーザーに許可を求めるUIは逆効果だ。ユーザーは深く考えずに「許可」を押す。AndroidiOSの経験から、我々はそれを知っている。

Zellijのパーミッションモデルが防いでいるのは、むしろ「うっかりファイルを消してしまうバグ」や「意図せずネットワークにアクセスしてしまう問題」だ。FullHdAccessを持つプラグインがファイルを誤削除するリスクを、ユーザーが明示的に受け入れる——そういう設計だと理解している。

許可されたパーミッションPermissionCacheプラグイン名ごとに保存され、次回起動時は再確認されない。これも「毎回聞かれると面倒」という実用性を優先した判断だ。

ANSIエスケープシーケンスのパース

ここまでプラグインの実行環境(WASM)、通信手段(Protocol Buffers)、安全性(パーミッション)を見てきた。ここからはサーバー側の話に移る。シェルの出力をどう画面に変換するか——その起点がANSIエスケープシーケンスのパースだ。

ターミナルに表示される色付きの文字や、カーソルの移動は「ANSIエスケープシーケンス」で制御されている。\x1b[31mが「赤色」、\x1b[Hが「カーソルを左上に移動」。

zellij-server/src/panes/grid.rsを開くと、vteクレート(Alacrittyチームが保守)を使っている。

use vte::{Params, Perform};

impl Perform for Grid {
    // 通常文字の描画
    fn print(&mut self, c: char) {
        self.add_character(c);
    }

    // C0/C1制御文字(改行、タブなど)
    fn execute(&mut self, byte: u8) {
        match byte {
            b'\n' => self.move_cursor_down(1),
            b'\r' => self.move_cursor_to_beginning_of_line(),
            b'\t' => self.advance_to_next_tabstop(),
            _ => {}
        }
    }

    // CSIシーケンス(カーソル移動、色設定など)
    fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8],
                    _ignore: bool, action: char) {
        // \x1b[10;2H → カーソル移動
        // \x1b[36m → 色設定
    }
}

Performトレイトを実装するだけで、vteがパースした結果を受け取れる。ANSIエスケープシーケンスの仕様は複雑で、エッジケースも多い。自作するより、実績のあるクレートを使う方が合理的だ。

Alacrittyと同じクレートを使っている点も興味深い。ターミナルエミュレータの世界では、vteがデファクトスタンダードになりつつある。

差分レンダリングの実装

VTEパーサがANSIエスケープシーケンスを解釈し、Gridが更新される。次の問題は、そのGridをどう効率的に画面へ反映するかだ。全画面を毎回再描画すると遅い。zellij-server/src/output/mod.rsを見ると、変更された行だけを追跡している。

// zellij-server/src/output/mod.rs
pub struct OutputBuffer {
    pub changed_lines: HashSet<usize>,  // 変更行インデックス
    pub should_update_all_lines: bool,
    styled_underlines: bool,
}

impl Default for OutputBuffer {
    fn default() -> Self {
        OutputBuffer {
            changed_lines: HashSet::new(),
            should_update_all_lines: true,  // 初回は全画面レンダリング
            styled_underlines: true,
        }
    }
}

impl OutputBuffer {
    pub fn update_line(&mut self, line_index: usize) {
        if !self.should_update_all_lines {
            self.changed_lines.insert(line_index);
        }
    }

    pub fn clear(&mut self) {
        self.changed_lines.clear();
        self.should_update_all_lines = false;
    }
}

なぜ「行レベル」であり「セル単位」ではないのか

セル単位の差分追跡も技術的には可能だ。しかし、ターミナルの出力は行単位で更新されることが多く、1文字だけ変わるケースは稀だ。セル単位にすると、追跡のオーバーヘッドが差分レンダリングの利点を上回る可能性がある。

HashSetを使うことで、同じ行が複数回更新されても重複エントリが発生しない。シンプルだが効果的だ。

パフォーマンス最適化の成果

ここまで見てきた境界付きチャネルと差分レンダリングは、個別には小さな改善に見える。しかし、組み合わせると効果は大きい。開発者ブログによると、以下の最適化によりcat bigfileベンチマークが大幅に改善された。

ベンチマーク条件: - 測定コマンド: hyperfine --show-output "cat /tmp/bigfile"(10回実行の平均) - ファイルサイズ: 200万行 - ペインサイズ: 59行 × 104列

段階 時間
最適化前 19.175秒 ± 0.347秒
境界付きチャネル導入後 9.658秒 ± 0.095秒
全最適化後 5.270秒 ± 0.027秒
tmux(参考) 5.593秒

このベンチマークではtmuxと同等以上のパフォーマンスを達成している。ただし、マシンスペックやtmuxのバージョン・設定は記載されていない。実環境での性能はワークロードや設定に依存するため、「Zellijの方が常に速い」とは言えない。重要なのは、適切な最適化によってRust製の新参者が30年の歴史を持つtmuxと同等のパフォーマンスを達成できた点だ。

主な最適化は以下の4つだ。

  1. 境界付きチャネルによるバックプレッシャー: 19秒→9秒の最大の貢献
  2. Vecの事前確保: Vec::with_capacity()で再確保を削減
  3. Unicode幅のキャッシュ: 絵文字などの幅計算を毎回やらない
  4. 行レベル差分追跡: 変更行のみを再描画

どれも「当たり前」の最適化だ。しかし、当たり前のことを愚直にやるのは難しい。

ターミナル特有の問題への対処

ここまで、チャネル、WASM、Protocol Buffers、ANSIパーサー、差分レンダリングと見てきた。どれも汎用的なパターンの応用だ。ここからは違う。ターミナルエミュレータでなければ出会わない問題ばかりだ。ソースコードを読んでいて、一番面白かったのはこのあたりだった。

RcCharacterStyles: 16バイトに収めるメモリ効率化

ターミナルの文字列バッファは数百万の要素を持つ。1文字あたりのメモリサイズがパフォーマンスに直結する。

zellij-server/src/panes/terminal_character.rsを開く。

// Enum Niche Optimization: 2つのvariantしかないため、ポインタサイズと同じ8バイトに収まる
#[derive(Clone, Debug, PartialEq)]
pub enum RcCharacterStyles {
    Reset,
    Rc(Rc<CharacterStyles>),
}

// compile-time assertionでメモリサイズを保証
#[cfg(target_arch = "x86_64")]
const _: [(); 8] = [(); std::mem::size_of::<RcCharacterStyles>()];

// TerminalCharacter全体も16バイト
#[cfg(target_arch = "x86_64")]
const _: [(); 16] = [(); std::mem::size_of::<TerminalCharacter>()];

// thread_local!でデフォルトスタイルをキャッシュし、メモリ再利用
thread_local! {
    static RC_DEFAULT_STYLES: RcCharacterStyles =
        RcCharacterStyles::Rc(Rc::new(DEFAULT_STYLES));
}

impl Default for RcCharacterStyles {
    fn default() -> Self {
        RC_DEFAULT_STYLES.with(|s| s.clone())  // thread_localから共有参照を取得
    }
}

compile-time assertionが面白い。const _: [(); 16] = [(); std::mem::size_of::<TerminalCharacter>()];は、TerminalCharacterのサイズが16バイトでなければコンパイルエラーになる。将来フィールドを追加したとき、意図せずメモリサイズが増えることを防ぐ。

Enum Niche Optimization + Reference Counting + thread_localの組み合わせで、リセット状態の文字スタイルをメモリ効率的に管理している。型安全性を失わずに、大規模なパフォーマンス最適化を実現しているのが印象的だ。

PaneResizer: Cassowary制約ソルバーによるペイン配置

ペインのレイアウト計算は、意外と難しい。「固定サイズのペイン」と「パーセンテージ指定のペイン」が混在し、ウィンドウリサイズ時に全体を再計算する必要がある。

zellij-server/src/panes/tiled_panes/pane_resizer.rsを開く。

use cassowary::{
    strength::{REQUIRED, STRONG},
    Expression, Solver, Variable,
    WeightedRelation::EQ,
};

pub struct PaneResizer<'a> {
    panes: Rc<RefCell<HashMap<PaneId, &'a mut Box<dyn Pane>>>>,
    vars: HashMap<PaneId, Variable>,
    solver: Solver,
}

// 制約を設定: 「固定サイズペイン」と「パーセンテージペイン」の両方に対応
fn constrain_spans(space: usize, spans: &[Span]) -> HashSet<cassowary::Constraint> {
    let mut constraints = HashSet::new();

    // 全ペインの合計サイズは、利用可能なスペースと等しい(REQUIRED強度)
    let full_size = spans
        .iter()
        .fold(Expression::from_constant(0.0), |acc, s| acc + s.size_var);
    constraints.insert(full_size.clone() | EQ(REQUIRED) | space as f64);

    // 固定サイズはREQUIRED、パーセンテージはSTRONGで制約
    for span in spans {
        match span.size.constraint {
            Constraint::Fixed(s) => constraints.insert(span.size_var | EQ(REQUIRED) | s as f64),
            Constraint::Percent(p) => constraints
                .insert((span.size_var / new_flex_space as f64) | EQ(STRONG) | (p / 100.0)),
        };
    }

    constraints
}

// 丸め誤差の分配: error.signum()で±1ずつペインサイズを調整
for span in flex_spans {
    rounded_sizes
        .entry(span.size_var)
        .and_modify(|s| *s += error.signum());
    error -= error.signum();
}

Cassowary線形計画法を使った制約ソルバーだ。元々はmacOSのAuto Layoutに使われていたアルゴリズムで、それをペインレイアウトに応用している。

REQUIREDSTRONGの強度で優先度を管理するのが賢い。固定サイズのペインは絶対に守られ、パーセンテージ指定のペインは「できるだけ守る」という柔軟性を持つ。

error.signum()丸め誤差を1ピクセルずつ分配するのも秀逸だ。浮動小数点の計算結果を整数に変換すると、どうしても誤差が出る。その誤差を均等にばらまくことで、ギャップやオーバーラップを回避している。

HyperlinkTracker: カーソルジャンプ検出によるURL追跡

ターミナルでURLをクリック可能にするには、「文字列がURLかどうか」を検出する必要がある。しかし、ターミナルは1文字ずつ出力されるため、URLの開始と終了を正確に把握するのは難しい。

zellij-server/src/panes/hyperlink_tracker.rsを開く。

pub struct HyperlinkTracker {
    buffer: String,
    cursor_positions: Vec<HyperlinkPosition>,  // 各文字のカーソル位置を記録
    start_position: Option<HyperlinkPosition>,
    last_cursor: Option<HyperlinkPosition>,    // カーソルジャンプ検出用
}

// カーソルが「連続的に移動していない」ことを検出
fn should_reset_due_to_cursor_jump(&self, current_pos: &HyperlinkPosition) -> bool {
    if let Some(last_pos) = &self.last_cursor {
        let is_contiguous =
            // 同一行の隣(通常の文字出力)
            (current_pos.y == last_pos.y && current_pos.x == last_pos.x + 1) ||
            // 改行(行の折り返し)
            (current_pos.y == last_pos.y + 1 && current_pos.x == 0) ||
            // 同じ位置(上書き)
            (current_pos.y == last_pos.y && current_pos.x == last_pos.x);
        !is_contiguous
    } else {
        false
    }
}

カーソルジャンプ = URLの中断と判定するのが面白い。

例えば、https://example.comと出力される途中で、プロンプトに戻るためにカーソルが左上にジャンプしたら、URLは完了したと見なす。複数行にまたがるURLや、ターミナルの折り返しにも対応している。

Sixel画像: 負の座標とオーバーラップ判定

Sixelは、ターミナル内に画像を表示するための古い規格だ。Zellijはこれをサポートしているが、スクロール時の挙動が複雑になる。

zellij-server/src/panes/sixel.rsを開く。

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct PixelRect {
    pub x: usize,
    pub y: isize,  // 負の値対応!スクロールバッファの上部に消えた画像
    pub width: usize,
    pub height: usize,
}

// 新しい画像が古い画像を完全に覆った場合、古い画像を削除
for (image_id, pixel_rect) in &self.sixel_image_locations {
    if let Some(intersecting_rect) = pixel_rect.intersecting_rect(&image_size_and_coordinates) {
        if intersecting_rect.x == pixel_rect.x
            && intersecting_rect.y == pixel_rect.y
            && intersecting_rect.height == pixel_rect.height
            && intersecting_rect.width == pixel_rect.width
        {
            self.image_ids_to_reap.push(*image_id);  // 完全に覆われた→削除予定
        }
    }
}

y: isizeが興味深い。スクロールで画像がバッファの上部に消えると、yが負の値になる。usizeではなくisizeにすることで、この状況を型で表現している。

完全にオーバーラップした画像は自動でメモリ解放される。Sixel画像は計算コストが高いため、不要な画像を積極的に削除するのは合理的だ。

ダブルクリック検出: Doherty Threshold

マウスのダブルクリック検出には、時間閾値が必要だ。ZellijはDoherty Thresholdという値を使っている。

zellij-server/src/panes/grid.rsを開く。

const CLICK_TIME_THRESHOLD: u128 = 400;  // Doherty Threshold

impl Click {
    pub fn record_click(&mut self, position: Position) {
        let click_is_same_position = self.position_and_time
            .map(|(p, _t)| p == position)
            .unwrap_or(false);
        let click_is_within_time_threshold = self.position_and_time
            .map(|(_p, t)| t.elapsed().as_millis() <= CLICK_TIME_THRESHOLD)
            .unwrap_or(false);

        if click_is_same_position && click_is_within_time_threshold {
            self.count += 1;
        } else {
            self.count = 1;
        }

        if self.count == 4 {
            self.reset();  // 3クリックまで(単語選択、行選択、段落選択)
        }
    }
}

400msという数字は、1982年のDoherty & Kelisky論文に由来する。「ユーザーがシステムの反応を待てる限界」とされる時間だ。

3クリックまで対応しているのも面白い。1クリック=カーソル移動、2クリック=単語選択、3クリック=行選択。4クリック目でリセットされる。

クライアント側のターミナル制御

PTYの実装に入る前に、クライアント側の処理を見ておく必要がある。ユーザーのキー入力がサーバーに届くまでの道筋——つまり、データフローの入口だ。

Raw Mode vs Cooked Mode

追加知識セクションでtermios構造体とRaw Modeの概念を紹介した。Zellijの実装では、具体的にどうRaw Modeに入るのか。

通常のターミナルは「Cooked Mode」で動作する。カーネルが行編集(バックスペース、Ctrl+Wなど)を処理し、Enterで1行ずつアプリケーションに渡す。Ctrl+Cを押すとSIGINTが送られる。

Zellijはこれを無効にする必要がある。すべてのキー入力を自分で処理したいからだ。

zellij-client/src/os_input_output.rsを開く。

fn into_raw_mode(pid: RawFd) {
    let mut tio = termios::tcgetattr(pid).expect("could not get terminal attribute");
    termios::cfmakeraw(&mut tio);
    match termios::tcsetattr(pid, termios::SetArg::TCSANOW, &tio) {
        Ok(_) => {},
        Err(e) => panic!("error {:?}", e),
    };
}

fn unset_raw_mode(pid: RawFd, orig_termios: termios::Termios) -> Result<(), nix::Error> {
    termios::tcsetattr(pid, termios::SetArg::TCSANOW, &orig_termios)
}

cfmakeraw() は以下を無効にする。

  • ECHO: 入力文字のエコーバック
  • ICANON: 行単位の入力(カノニカルモード)
  • ISIG: Ctrl+C/Ctrl+Zによるシグナル生成
  • IXON/IXOFF: ソフトウェアフロー制御(Ctrl+S/Ctrl+Q)

TCSANOW は「今すぐ適用」を意味する。TCSADRAIN(出力完了後)やTCSAFLUSH(バッファ破棄)もあるが、入力処理では即座の適用が必要だ。

元のtermiosを保存しておき、終了時に復元する。これを忘れると、Zellij終了後にターミナルが壊れた状態になる。

SIGWINCHのスロットリング

ターミナルウィンドウをリサイズすると、OSはSIGWINCHを送る。問題は、GUIウィンドウをドラッグでリサイズすると、1秒間に数十〜数百回のシグナルが発火することだ。

fn handle_signals(&self, sigwinch_cb: Box<dyn Fn()>, quit_cb: Box<dyn Fn()>) {
    let mut sigwinch_cb_timestamp = time::Instant::now();
    let mut signals = Signals::new(&[SIGWINCH, SIGTERM, SIGINT, SIGQUIT, SIGHUP]).unwrap();
    for signal in signals.forever() {
        match signal {
            SIGWINCH => {
                // SIGWINCHコールバックをスロットリング
                if sigwinch_cb_timestamp.elapsed() < SIGWINCH_CB_THROTTLE_DURATION {
                    thread::sleep(SIGWINCH_CB_THROTTLE_DURATION);
                }
                sigwinch_cb_timestamp = time::Instant::now();
                sigwinch_cb();
            },
            SIGTERM | SIGINT | SIGQUIT | SIGHUP => {
                quit_cb();
                break;
            },
            _ => unreachable!(),
        }
    }
}

SIGWINCH_CB_THROTTLE_DURATIONは50msだ。リサイズイベントが来ても、前回から50ms経っていなければ待機する。これにより、毎秒数十回のレンダリングを防ぐ。

50msという値は経験則だ。人間が「遅延」と感じる閾値(100ms)より短く、ターミナルが処理できる頻度(60fps = 16ms)より長い。

同期出力(Synchronized Output)

最新のターミナルエミュレータは「同期出力」をサポートしている。複数の出力をバッファリングし、まとめて画面に反映する機能だ。

let synchronised_output = match os_input.env_variable("TERM").as_deref() {
    Some("alacritty") => Some(SyncOutput::DCS),
    _ => None,
};

// レンダリング時
if let Some(sync) = synchronised_output {
    stdout.write_all(sync.start_seq()).expect("cannot write to stdout");
}
stdout.write_all(output.as_bytes()).expect("cannot write to stdout");
if let Some(sync) = synchronised_output {
    stdout.write_all(sync.end_seq()).expect("cannot write to stdout");
}

DCS(Device Control String)で出力を囲む。ターミナルはDCS開始からDCS終了までの出力をバッファリングし、終了シーケンスを受け取った時点でまとめて描画する。

これにより、レイアウト変更時の「ちらつき」が消える。中間状態(ペインが1つだけ描画された状態など)が画面に表示されない。

現時点ではAlacrittyのみ対応だが、今後他のターミナルにも拡大されるだろう。

Kitty Keyboard Protocol

従来のターミナルは、Shift+F1とF13を区別できなかった。どちらも同じエスケープシーケンスを送るからだ。Kitty Keyboard Protocolはこの問題を解決する。

zellij-client/src/stdin_handler.rsを開く。

loop {
    match os_input.read_from_stdin() {
        Ok(buf) => {
            // まずKitty Keyboard Protocolを試す
            if !explicitly_disable_kitty_keyboard_protocol {
                match KittyKeyboardParser::new().parse(&buf) {
                    Some(key_with_modifier) => {
                        send_input_instructions.send(...).unwrap();
                        continue;
                    },
                    None => {},
                }
            }

            // フォールバック: 標準のtermwiz InputParser
            input_parser.parse(&buf, |input_event| { ... }, false);
        },
        // ...
    }
}

Kitty Keyboard Protocolが使えるなら使い、使えなければ従来のANSIエスケープシーケンスにフォールバックする。この二段構えにより、古いターミナルでも動作しつつ、新しいターミナルでは拡張機能を活用できる。

セッション切り替え時のSTDINバッファリング

Zellijは複数のセッションを持てる。セッション間を切り替えるとき、STDINの所有権を移す必要がある。

fn read_from_stdin(&mut self) -> Result<Vec<u8>, &'static str> {
    let session_name_at_calltime = { self.session_name.lock().unwrap().clone() };

    let mut buffered_bytes = self.reading_from_stdin.lock().unwrap();
    match buffered_bytes.take() {
        Some(buffered_bytes) => Ok(buffered_bytes),
        None => {
            let stdin = std::io::stdin();
            let mut stdin = stdin.lock();
            let buffer = stdin.fill_buf().unwrap();
            let length = buffer.len();
            let read_bytes = Vec::from(buffer);
            stdin.consume(length);

            // セッションが変わったら、読んだバイトをバッファに戻す
            let session_name_after_reading_from_stdin =
                { self.session_name.lock().unwrap().clone() };
            if session_name_at_calltime.is_some()
                && session_name_at_calltime != session_name_after_reading_from_stdin
            {
                *buffered_bytes = Some(read_bytes);
                Err("Session ended")
            } else {
                Ok(read_bytes)
            }
        },
    }
}

問題: 旧セッションのスレッドがSTDINでブロックしている間に、新セッションがSTDINを必要とする。

解決策: 読み取り前後でセッション名を比較する。セッションが変わっていたら、読んだバイトをバッファに保存し、新セッションのスレッドがそれを取得できるようにする。

これはエッジケースだが、マルチセッション対応には必須の処理だ。

PTY(疑似端末)の実装詳細

ここまで見てきた境界付きチャネル、VTEパーサ、差分レンダリング、compile-time assertion——これらはすべて、PTYという土台の上に乗っている。チャネルはPTYからの出力を運び、VTEパーサはPTYが吐いたバイト列を解釈し、差分レンダリングはその結果を画面に描く。個別のパターンを追いかけてきたが、ここで全体が合流する。

ターミナルマルチプレクサの核心部分であるPTY処理を深掘りする。ここがZellijの「心臓部」だ。

そもそもTTYとPTYとは何か

TTY(TeleTYpewriter)は、歴史的にはテレタイプ端末を指す。現代では「ターミナルデバイス」の総称として使われる。/dev/tty/dev/tty1がこれにあたる。

PTY(Pseudo-TTY)は「疑似端末」だ。物理的な端末がなくても、ソフトウェア的にターミナルをエミュレートする仕組み。sshやtmux、そしてZellijはPTYを使っている。

PTYはマスタースレーブのペアで構成される。

[Zellij Server] ←→ [PTY Master] ←→ [PTY Slave] ←→ [Shell/App]
                    (制御側)         (端末側)
  • マスター側: Zellijが持つ。ここに書き込むと、スレーブ側のSTDINに届く。スレーブの出力はここから読める
  • スレーブ側: シェル(bash/zsh)が持つ。通常のターミナルと同じように振る舞う

シェルから見ると、スレーブPTYは「本物のターミナル」に見える。ttyコマンドを実行すると/dev/pts/0のようなパスが返る。これがスレーブPTYだ。

Zellijがペインを作るたびに、新しいPTYペアが生成される。3ペインあれば、3つのPTYマスターをZellijが管理している。

なぜPTYが必要なのか

「パイプでいいのでは?」と思うかもしれない。しかし、パイプとPTYには決定的な違いがある。

  1. ジョブコントロール: PTYは「制御端末」として機能する。Ctrl+Zでプロセスを停止したり、fg/bgで制御したりできるのは、PTYがあるからだ。パイプにはこの機能がない

  2. ウィンドウサイズ: PTYはサイズ(行数・列数)を持つ。$COLUMNS$LINESはPTYから取得される。パイプにはサイズの概念がない

  3. 行編集: シェルは「端末があるかどうか」で挙動を変える。isatty()trueを返すと、プロンプトを表示し、行編集を有効にする。パイプ経由だとこれが無効になる

  4. シグナル: Ctrl+CでSIGINTを送れるのは、PTYが「フォアグラウンドプロセスグループ」を管理しているからだ

つまり、PTYなしには「ターミナルらしい体験」が成り立たない。

PTYの作成フロー

zellij-server/src/os_input_output.rsを開く。

fn handle_openpty(
    open_pty_res: OpenptyResult,
    cmd: RunCommand,
    quit_cb: Box<dyn Fn(PaneId, Option<i32>, RunCommand) + Send>,
    terminal_id: u32,
) -> Result<(RawFd, RawFd)> {
    let pid_primary = open_pty_res.master;    // Zellij側(ホスト)
    let pid_secondary = open_pty_res.slave;   // シェル側(子プロセス)

    let mut child = unsafe {
        Command::new(cmd.command)
            .args(&cmd.args)
            .env("ZELLIJ", VERSION)
            .env("ZELLIJ_SESSION_NAME", &*SESSION_NAME)
            .env("ZELLIJ_PANE_ID", &format!("{}", terminal_id))
            .pre_exec(move || -> std::io::Result<()> {
                if libc::login_tty(pid_secondary) != 0 {
                    panic!("failed to set controlling terminal");
                }
                close_fds::close_open_fds(3, &[]);
                Ok(())
            })
            .spawn()
            .expect("failed to spawn")
    };

    // 子プロセスの終了を監視するスレッドを起動
    std::thread::spawn({
        move || {
            child.wait().ok();
            let exit_status = child.try_wait().ok().flatten().and_then(|e| e.code());
            quit_cb(PaneId::Terminal(terminal_id), exit_status, cmd);
        }
    });

    Ok((pid_primary, child.id() as RawFd))
}

openpty() はマスターとスレーブの2つのファイルディスクリプタを作る。Zellijはマスター側を持ち、シェル(bashzsh)はスレーブ側を持つ。

login_tty() は伝統的なUnix関数で、3つのことをする。

  1. setsid() で新しいセッションを作成
  2. スレーブPTYをcontrolling terminalに設定
  3. dup2() でstdin/stdout/stderrをスレーブに接続

close_fds::close_open_fds(3, &[]) が面白い。ファイルディスクリプタ3以降を全て閉じる。これにより、親プロセスから継承した不要なFDがリークしない。

環境変数の設定も注目に値する。ZELLIJ_PANE_IDを設定することで、子プロセス側から「自分がどのペインで動いているか」を知ることができる。シェルスクリプトプラグインで使える。

読み取りと書き込みの分離

PTYへの読み書きは、別々のスレッドで行われる。なぜか。

zellij-server/src/terminal_bytes.rsを開く。

pub(crate) struct TerminalBytes {
    pid: RawFd,
    terminal_id: u32,
    senders: ThreadSenders,
    async_reader: Box<dyn AsyncReader>,
    debug: bool,
}

impl TerminalBytes {
    pub async fn listen(&mut self) -> Result<()> {
        let mut buf = [0u8; 65536];  // 64KBバッファ
        loop {
            match self.async_reader.read(&mut buf).await {
                Ok(0) => break,  // EOF(プロセス終了)
                Err(err) => {
                    log::error!("{}", err);
                    break;
                },
                Ok(n_bytes) => {
                    let bytes = &buf[..n_bytes];
                    self.async_send_to_screen(ScreenInstruction::PtyBytes(
                        self.terminal_id,
                        bytes.to_vec(),
                    ))
                    .await?;
                },
            }
        }
        // ループ終了後、最終レンダリングを要求
        let _ = self.async_send_to_screen(ScreenInstruction::Render).await;
        Ok(())
    }
}

64KBバッファ。一般的な8KBや4KBではなく、大きめのサイズだ。大量のログ出力に対応するため。

Ok(0)Errの区別が重要。Ok(0)はEOF(プロセスが終了した)、Errは本当のエラー。この区別を間違えると、プロセス正常終了時にエラーログが出てしまう。

zellij-server/src/pty_writer.rsを開く。

pub(crate) fn pty_writer_main(bus: Bus<PtyWriteInstruction>) -> Result<()> {
    loop {
        let (event, _err_ctx) = bus.recv()?;
        match event {
            PtyWriteInstruction::Write(bytes, terminal_id) => {
                if let Some(raw_fd) = bus
                    .os_input
                    .as_ref()
                    .and_then(|os_input| os_input.get_terminal_id_from_fd(terminal_id))
                {
                    let mut f = unsafe { File::from_raw_fd(*raw_fd) };
                    if f.write_all(&bytes).is_ok() {
                        let _ = f.flush();
                    }
                    std::mem::forget(f);  // FDを閉じないようにする
                }
            },
            PtyWriteInstruction::Exit => break,
        }
    }
    Ok(())
}

読み取りと書き込みを分離する理由は、デッドロック回避だ。

Vimのようなプログラムは、STDINからの入力を待ちながらSTDOUTに出力する。もし同じスレッドで読み書きをすると、「Vimが入力を待っている」「Zellijが出力を待っている」という状態でデッドロックになる可能性がある。

std::mem::forget(f) も興味深い。File::from_raw_fd()で作ったFileは、dropされるとFDが閉じてしまう。forget()dropを防いでいる。

forgetという名前は不穏だ。普通、忘れることは悪いことだ。しかしRustの所有権モデルでは、意図的に忘れることが正しい選択になる場合がある。FDを閉じたくないなら、Fileがdropされることを忘れさせる。記憶と忘却の関係が、ここでは逆転している。

tcdrainによるフロー制御

書き込みスレッドには、もう一つ重要な処理がある。

PtyWriteInstruction::Write(bytes, terminal_id) => {
    os_input
        .write_to_tty_stdin(terminal_id, &bytes)
        .with_context(err_context)
        .non_fatal();
    os_input
        .tcdrain(terminal_id)  // ここ
        .with_context(err_context)
        .non_fatal();
},

tcdrain() は、書き込んだデータが全て送信されるまでブロックする。なぜこれが必要か。

PTYにはカーネル内部にバッファがある。write()はバッファに書き込んだ時点で返る。しかし、相手側(シェル)がまだ読んでいない可能性がある。

tcdrain()を呼ぶと、バッファが空になるまで待機する。これにより、次の書き込みが前の書き込みを追い越すことを防ぐ。

fn tcdrain(&self, terminal_id: u32) -> Result<()> {
    match self
        .terminal_id_to_raw_fd
        .lock()
        .to_anyhow()
        .with_context(err_context)?
        .get(&terminal_id)
    {
        Some(Some(fd)) => termios::tcdrain(*fd).with_context(err_context),
        _ => Err(anyhow!("could not find raw file descriptor")).with_context(err_context),
    }
}

ソースコードのコメントには、こうある。「VimのようなプログラムはSTDINに書き込みながらSTDOUTを読むとデッドロックする」。Vimへの言及が具体的で、実際に遭遇した問題なのだろう。

ウィンドウリサイズの処理

ターミナルをリサイズしたとき、PTYにサイズ変更を伝える必要がある。

zellij-server/src/os_input_output.rsを開く。

fn set_terminal_size_using_terminal_id(
    &self,
    id: u32,
    cols: u16,
    rows: u16,
    width_in_pixels: Option<u16>,
    height_in_pixels: Option<u16>,
) -> Result<()> {
    // リサイズをキャッシュ(複数のリサイズイベントを1つにまとめる)
    match self.cached_resizes.lock() {
        Ok(mut cached_resizes) => {
            let cached_resizes = cached_resizes.get_or_insert_with(BTreeMap::new);
            cached_resizes.insert(id, (cols, rows, width_in_pixels, height_in_pixels));
        },
        Err(e) => {
            log::error!("Failed to cache resize: {}", e);
        },
    }

    // キャッシュを適用
    self.apply_cached_resizes()
}

fn apply_cached_resizes(&self) -> Result<()> {
    if let Some(cached_resizes) = self.cached_resizes.lock().ok().as_mut().and_then(|c| c.take()) {
        for (terminal_id, (cols, rows, width_in_pixels, height_in_pixels)) in cached_resizes {
            let ws = Winsize {
                ws_row: rows,
                ws_col: cols,
                ws_xpixel: width_in_pixels.unwrap_or(0),
                ws_ypixel: height_in_pixels.unwrap_or(0),
            };
            if let Some(raw_fd) = self.get_terminal_id_from_fd(terminal_id) {
                set_terminal_size_using_fd(*raw_fd, ws);
            }
        }
    }
    Ok(())
}

fn set_terminal_size_using_fd(fd: RawFd, ws: Winsize) {
    // TIOCSWINSZ ioctlでPTYにサイズを伝える
    if let Err(e) = ioctl_set_window_size(fd, &ws) {
        log::error!("Failed to set terminal size: {}", e);
    }
}

リサイズのキャッシングが重要だ。なぜか。

ソースコードのコメントに理由がある。「レイアウト計算のコードには、複数のリサイズを送信してしまうロジックの罠がある。最後の1つだけが正しいのだが、多くのプログラムやシェルはリサイズをデバウンスする(GUIウィンドウのリサイズに対処してきたトラウマだろう)。これがグリッチや描画漏れを引き起こす」。

つまり、VimZshは「リサイズが連続で来たら、最後の1つだけ処理する」という防御策を持っている。Zellijが中間のリサイズも送ると、シェル側のデバウンスと競合してしまう。だからZellij側でもキャッシュし、最後の1つだけを送る。

PtyWriteInstruction::StartCachingResizes => {
    // レイアウト再計算中はリサイズをキャッシュ
    os_input.cache_resizes();
},
PtyWriteInstruction::ApplyCachedResizes => {
    // 計算完了後、最後のリサイズだけを適用
    os_input.apply_cached_resizes();
},

TIOCSWINSZ(Terminal I/O Control Set WINdow SiZe)は、PTYにサイズを伝えるioctlだ。これを呼ぶと、カーネルは子プロセスグループにSIGWINCHを送る。子プロセスはこのシグナルを受けて、画面を再描画する。

ws_xpixelws_ypixel はSixel画像のために使われる。文字単位のサイズ(cols/rows)に加えて、ピクセル単位のサイズも伝える。これにより、画像を正確な解像度で表示できる。

プロセス終了の検出とシグナル

ペインを閉じるとき、子プロセスにシグナルを送る必要がある。

pub fn close_pane(&mut self, id: PaneId) -> Result<()> {
    match id {
        PaneId::Terminal(id) => {
            self.task_handles.remove(&id);  // 読み取りタスクを停止
            if let Some(child_fd) = self.id_to_child_pid.remove(&id) {
                task::block_on(async {
                    self.bus
                        .os_input
                        .as_mut()
                        .fatal()
                        .kill(Pid::from_raw(child_fd))  // SIGHUPを送信
                        .fatal();
                });
            }
        },
        PaneId::Plugin(pid) => {
            // プラグインはUnload命令を送るだけ
            drop(self.bus.senders.send_to_plugin(PluginInstruction::Unload(pid)));
        },
    }
    Ok(())
}

シグナルの送信順序も工夫されている。

// SIGTERMを3回試行し、それでも終了しなければSIGKILL
for _ in 0..3 {
    if nix::sys::signal::kill(pid, Signal::SIGTERM).is_ok() {
        std::thread::sleep(Duration::from_millis(10));
        // プロセスが終了したかチェック
        if nix::sys::wait::waitpid(pid, Some(WaitPidFlag::WNOHANG)).is_ok() {
            return Ok(());
        }
    }
}
// 3回失敗したらSIGKILL
nix::sys::signal::kill(pid, Signal::SIGKILL)?;

SIGTERM 3回 → SIGKILL。プロセスに「正常終了」の機会を与えつつ、応答しなければ強制終了する。10msのポーリング間隔も絶妙だ。

CWD(カレントディレクトリ)の追跡

ペインのカレントディレクトリを追跡するのは、意外と難しい。

fn get_cwd(&self, pid: Pid) -> Option<PathBuf> {
    // /proc/[pid]/cwd を読み取る
    let path = format!("/proc/{}/cwd", pid);
    std::fs::read_link(path).ok()
}

pub fn update_and_report_cwds(&mut self) {
    let terminal_ids: Vec<u32> = self.id_to_child_pid.keys().copied().collect();

    let pids: Vec<_> = terminal_ids
        .iter()
        .filter_map(|id| self.id_to_child_pid.get(&id))
        .map(|pid| Pid::from_raw(*pid))
        .collect();

    // 全ペインのCWDを一括取得
    let (pids_to_cwds, _) = self
        .bus
        .os_input
        .as_ref()
        .map(|os_input| os_input.get_cwds(pids))
        .unwrap_or_default();

    // 変更があればクライアントに通知
    for terminal_id in terminal_ids {
        let cwd = /* ... */;
        if self.terminal_cwds.get(&terminal_id) != Some(cwd) {
            // CWD変更イベントを送信
        }
    }
}

/proc/[pid]/cwdLinux固有の仕組みだ。プロセスのカレントディレクトリへのシンボリックリンクになっている。これを定期的にチェックすることで、cdコマンドによるディレクトリ変更を検出できる。

データフローの全体像

PTYを通じたデータの流れを図にすると、以下のようになる。

[ユーザー入力]
     ↓ キー入力
[Client Process]
     ↓ IPC (Unix Domain Socket)
[Server Process]
     ↓ PtyWriteInstruction::Write
[PTY Writer Thread]
     ↓ write() to master FD
[PTY (カーネル)]
     ↓
[Shell/アプリケーション]
     ↓ 出力
[PTY (カーネル)]
     ↓ read() from master FD
[TerminalBytes::listen()]
     ↓ ScreenInstruction::PtyBytes
[Screen Thread]
     ↓ VTEパーサ
[Grid構造体]
     ↓ 差分レンダリング
[Client Process]
     ↓ IPC
[ユーザーの画面]

6つのスレッドが協調して動いている。

  • PTY Thread: PTYの作成・管理
  • PTY Writer Thread: PTYへの書き込み
  • TerminalBytes(async task): PTYからの読み取り
  • Screen Thread: VTEパース、レンダリング
  • Plugin Thread: WASMプラグイン実行
  • Background Thread: 非同期タスク

この分離により、どこか1つがブロックしても他の処理は継続できる。Vimが入力を待っている間も、他のペインは正常に動作する。

実装の詳細:システムコールのシーケンス

ここまで概念的な説明が多かった。実際にどのシステムコールがどの順序で呼ばれるのか、具体的に見ていこう。

PTY作成シーケンス

1. openpty(None, &orig_termios)
   → OpenptyResult { master: RawFd, slave: RawFd }

2. fork() [Command::spawn()が内部で呼ぶ]

3. 子プロセス(シェル側):
   - libc::login_tty(slave)
     - setsid()      // 新しいセッション作成
     - ioctl(slave, TIOCSCTTY)  // 制御端末として設定
     - dup2(slave, 0)  // stdin
     - dup2(slave, 1)  // stdout
     - dup2(slave, 2)  // stderr
   - close_open_fds(3, &[])  // FD 3以降を全て閉じる
   - exec("/bin/bash")  // シェルを起動

4. 親プロセス(Zellij側):
   - master FDを保存
   - 子プロセスのPIDを保存
   - 非同期読み取りタスクを起動
   - シグナルハンドラスレッドを起動

PTY読み取りシーケンス

1. async_reader.read(&mut buf[65536])
   → read(master_fd, buf, 65536) syscall
   → bytes受信 or EOF(0) or error

2. ScreenInstruction::PtyBytes(terminal_id, bytes)
   をチャネル経由でScreenスレッドに送信

3. Screenスレッドがbytesを受信
   → VTEパーサに渡す
   → Gridを更新

PTY書き込みシーケンス

1. PtyWriteInstruction::Write(bytes, terminal_id)
   をチャネル経由で受信

2. terminal_id_to_raw_fd マップからmaster FDを取得

3. write(master_fd, bytes) syscall
   → バイトがPTYバッファに書き込まれる

4. tcdrain(master_fd) syscall
   → 書き込みが完了するまで待機

ターミナルリサイズシーケンス

1. PtyWriteInstruction::ResizePty(terminal_id, cols, rows, ...)
   をチャネル経由で受信

2. terminal_id_to_raw_fd マップからmaster FDを取得

3. ioctl(master_fd, TIOCSWINSZ, &Winsize { ws_col, ws_row, ... })
   → カーネルがSIGWINCHを子プロセスグループに送信
   → シェルが再描画

実装の詳細:スレッドの起動

サーバープロセスの起動時、複数のスレッドが生成される。

zellij-server/src/lib.rsを開く。

pub fn start_server(mut os_input: Box<dyn ServerOsApi>, socket_path: PathBuf) {
    // チャネルの作成
    let (to_server, server_receiver): ChannelWithContext<ServerInstruction> =
        channels::bounded(50);
    let (to_screen, screen_receiver): ChannelWithContext<ScreenInstruction> =
        channels::bounded(50);
    let (to_pty, pty_receiver): ChannelWithContext<PtyInstruction> =
        channels::bounded(50);
    let (to_plugin, plugin_receiver): ChannelWithContext<PluginInstruction> =
        channels::bounded(50);
    let (to_pty_writer, pty_writer_receiver): ChannelWithContext<PtyWriteInstruction> =
        channels::unbounded();  // 書き込みは無制限

    // PTY Writerスレッド
    thread::Builder::new()
        .name("pty_writer".to_string())
        .spawn(move || {
            pty_writer_main(pty_writer_bus).fatal();
        })
        .unwrap();

    // PTYスレッド
    thread::Builder::new()
        .name("pty".to_string())
        .spawn(move || {
            pty_thread_main(pty_bus, pty_receiver).fatal();
        })
        .unwrap();

    // Screenスレッド
    thread::Builder::new()
        .name("screen".to_string())
        .spawn(move || {
            screen_thread_main(screen_bus, screen_receiver).fatal();
        })
        .unwrap();

    // Pluginスレッド
    thread::Builder::new()
        .name("plugin".to_string())
        .spawn(move || {
            plugin_thread_main(plugin_bus, plugin_receiver).fatal();
        })
        .unwrap();

    // Serverスレッド(メインループ)
    loop {
        let (instruction, err_ctx) = server_receiver.recv().expect("...");
        match instruction {
            ServerInstruction::NewClient(client_attributes, ...) => { ... },
            ServerInstruction::Render(serialized_output) => { ... },
            ServerInstruction::UnblockInputThread => { ... },
            ServerInstruction::ClientExit(client_id) => { ... },
            ServerInstruction::KillSession => break,
            // ...
        }
    }
}

注目すべきは、PTY Writerのチャネルだけunbounded()になっている点だ。他はbounded(50)でバックプレッシャーをかけているが、書き込みはブロックさせたくない。ユーザーの入力を遅延させると体感が悪くなるからだ。

実装の詳細:キーボード入力からシェルまでの経路

ユーザーがキーを押してからシェルに届くまでの、実際のコード経路を追う。

1. クライアントがキー入力を受信

zellij-client/src/stdin_handler.rs

loop {
    match os_input.read_from_stdin() {
        Ok(buf) => {
            // KittyプロトコルまたはANSIエスケープをパース
            input_parser.parse(&buf, |input_event| {
                send_input_instructions
                    .send(InputInstruction::KeyEvent(
                        input_event.clone(),
                        buf.to_vec(),
                    ))
                    .unwrap();
            }, false);
        },
        // ...
    }
}

2. クライアントがサーバーにメッセージ送信

zellij-client/src/lib.rs

InputInstruction::KeyEvent(key_event, raw_bytes) => {
    send_client_instructions
        .send(ClientInstruction::Action(
            Action::Write(None, raw_bytes, false),
            None,
            None,
        ))
        .unwrap();
}

3. サーバーのRouteスレッドがメッセージ受信

zellij-server/src/route.rs

fn route_action(action: Action, ...) -> bool {
    match action {
        Action::Write(_, bytes, _) => {
            session
                .senders
                .send_to_screen(ScreenInstruction::WriteCharacter(bytes, client_id))
                .unwrap();
        },
        // ...
    }
}

4. Screenスレッドがペインに書き込み指示

zellij-server/src/screen.rs

ScreenInstruction::WriteCharacter(bytes, client_id) => {
    let active_tab = screen.get_active_tab_mut(client_id).unwrap();
    active_tab.write_to_terminal(bytes, client_id);
}

5. TabがPTY Writerに書き込み指示

zellij-server/src/tab/mod.rs

pub fn write_to_active_terminal(&mut self, bytes: Vec<u8>, client_id: ClientId) {
    if let Some(active_pane_id) = self.get_active_pane_id(client_id) {
        if let PaneId::Terminal(terminal_id) = active_pane_id {
            self.senders
                .send_to_pty_writer(PtyWriteInstruction::Write(bytes, terminal_id))
                .unwrap();
        }
    }
}

6. PTY Writerスレッドが実際に書き込み

zellij-server/src/pty_writer.rs

PtyWriteInstruction::Write(bytes, terminal_id) => {
    os_input.write_to_tty_stdin(terminal_id, &bytes)?;
    os_input.tcdrain(terminal_id)?;
}

7. OSレイヤーでシステムコール

zellij-server/src/os_input_output.rs

fn write_to_tty_stdin(&self, terminal_id: u32, buf: &[u8]) -> Result<usize> {
    let fd = self.terminal_id_to_raw_fd.lock()?.get(&terminal_id)?;
    let mut file = unsafe { File::from_raw_fd(*fd) };
    let result = file.write(buf);
    std::mem::forget(file);  // FDを閉じない
    result
}

この経路で、キーボード入力は6つのコンポーネントを経由してシェルに届く。各コンポーネント間はチャネルで接続されている。

実装の詳細:シェル出力から画面までの経路

逆方向、シェルの出力が画面に表示されるまでの経路。

1. TerminalBytesが非同期で読み取り

zellij-server/src/terminal_bytes.rs

pub async fn listen(&mut self) -> Result<()> {
    let mut buf = [0u8; 65536];
    loop {
        match self.async_reader.read(&mut buf).await {
            Ok(0) => break,  // EOF
            Ok(n_bytes) => {
                let bytes = &buf[..n_bytes];
                self.async_send_to_screen(ScreenInstruction::PtyBytes(
                    self.terminal_id,
                    bytes.to_vec(),
                )).await?;
            },
            Err(err) => {
                log::error!("{}", err);
                break;
            },
        }
    }
    Ok(())
}

2. Screenスレッドがバイトを受信

zellij-server/src/screen.rs

ScreenInstruction::PtyBytes(terminal_id, bytes) => {
    // terminal_idからペインを特定
    if let Some(tab) = screen.get_tab_with_terminal_id(terminal_id) {
        tab.handle_pty_bytes(terminal_id, bytes);
    }
}

3. TabがVTEパーサに渡す

zellij-server/src/tab/mod.rs

pub fn handle_pty_bytes(&mut self, terminal_id: u32, bytes: Vec<u8>) {
    if let Some(pane) = self.panes.get_mut(&PaneId::Terminal(terminal_id)) {
        pane.handle_pty_bytes(bytes);
    }
}

4. TerminalPaneがGridを更新

zellij-server/src/panes/terminal_pane.rs

fn handle_pty_bytes(&mut self, bytes: Vec<u8>) {
    self.grid.advance_by_bytes(bytes);
}

5. GridがVTEパーサを実行

zellij-server/src/panes/grid.rs

pub fn advance_by_bytes(&mut self, bytes: Vec<u8>) {
    for byte in bytes {
        self.vte_parser.advance(&mut *self, byte);
    }
}

ここでvte_parservte::Parser型だ。Gridvte::Performトレイトを実装しており、パース結果に応じてprint()execute()csi_dispatch()などが呼ばれる。

6. レンダリングとクライアントへの送信

ScreenInstruction::Render => {
    screen.render()?;
    // 各クライアントに差分を送信
    for client_id in screen.connected_clients.keys() {
        let output = screen.render_for_client(*client_id)?;
        send_to_client(*client_id, ServerToClientMsg::Render(output))?;
    }
}

使用しているシステムコール一覧

Zellijが使用する主なシステムコールをまとめる。

システムコール 用途 使用箇所
openpty() PTYペアの作成 os_input_output.rs
fork() 子プロセスの作成 Command::spawn()
setsid() 新セッションの作成 login_tty() 内部
dup2() FDの複製 login_tty() 内部
exec() プログラムの実行 Command::spawn()
read() PTYからの読み取り terminal_bytes.rs
write() PTYへの書き込み pty_writer.rs
ioctl(TIOCGWINSZ) ウィンドウサイズ取得 os_input_output.rs
ioctl(TIOCSWINSZ) ウィンドウサイズ設定 os_input_output.rs
tcgetattr() termios取得 os_input_output.rs
tcsetattr() termios設定 os_input_output.rs
tcdrain() 出力完了待機 pty_writer.rs
kill() シグナル送信 os_input_output.rs
waitpid() 子プロセス待機 os_input_output.rs
close() FDのクローズ 各所

エラーハンドリングの実装

PTY関連のエラーは、致命的なものと非致命的なものに分類される。

// 非致命的: ログを出すが処理を継続
os_input
    .write_to_tty_stdin(terminal_id, &bytes)
    .with_context(err_context)
    .non_fatal();

// 致命的: パニックまたは終了
os_input
    .spawn_terminal(cmd, quit_cb)
    .with_context(err_context)
    .fatal();

non_fatal()は書き込みエラー、リサイズエラーなどに使われる。ペインが閉じられた後に書き込みが来ることがあり、これは正常な動作だ。

fatal()はPTY作成失敗、サーバー初期化失敗などに使われる。これらは回復不能なエラーだ。

コマンドが見つからない場合の処理も興味深い。

fn command_exists(cmd: &RunCommand) -> bool {
    // cwdからの相対パスをチェック
    if let Some(cwd) = cmd.cwd.as_ref() {
        let full_command = cwd.join(&cmd.command);
        if full_command.exists() && full_command.is_file() {
            return true;
        }
    }
    // PATHを検索
    if let Some(paths) = env::var_os("PATH") {
        for path in env::split_paths(&paths) {
            let full_command = path.join(&cmd.command);
            if full_command.exists() && full_command.is_file() {
                return true;
            }
        }
    }
    false
}

コマンドが存在しない場合、ZellijError::CommandNotFoundが返される。hold_on_closeが設定されていれば、エラーメッセージを表示したペインが残る。設定されていなければ、ペインは静かに閉じられる。

KDL形式によるセッション永続化

Zellijは1秒ごとにセッション状態を自動シリアライズする。保存先は~/.cache/zellij/<VERSION>/session_info/<SESSION_NAME>/だ。

zellij-utils/src/session_serialization.rsを開く。

// zellij-utils/src/session_serialization.rs
pub fn serialize_session_layout(
    global_layout_manifest: GlobalLayoutManifest,
) -> Result<(String, BTreeMap<String, String>), &'static str> {
    let mut document = KdlDocument::new();
    let mut layout_node = KdlNode::new("layout");
    // タブ、ペインの構造をKDLノードとして構築
}

生成されるKDL。

layout {
    cwd "/home/user/project"
    tab name="Editor" {
        pane command="vim" {
            args "src/main.rs"
            cwd "/home/user/project"
        }
    }
    tab name="Shell" {
        pane
    }
}

シリアライズ形式がそのまま有効なKDLレイアウトファイルになっている。これは特筆すべき設計だ。

なぜJSONやバイナリ形式ではないのか。JSONはパース速度が速いが、人間が編集するには冗長だ。KDLは「人間が読める設定ファイル」として設計された言語であり、Zellijのレイアウト設定にも使われている。

つまり設定ファイルとシリアライズ形式を統一することで、自動保存されたセッションをそのままレイアウトテンプレートとして再利用できる。

tmuxの.tmux.confとセッション復元は別系統だ。設定ファイルでは「こうあるべき」を書き、セッション復元では「こうだった」を読む。Zellijはこの2つを統一している。シンプルだが、これを思いつくのは難しい。

thiserror + anyhow の併用

前編でFatalErrorトレイトを紹介した。後編では、エラー型の定義側を見る。

// thiserrorによる型定義
#[derive(Debug, Error)]
pub enum ZellijError {
    #[error("could not find command '{command}' for terminal {terminal_id}")]
    CommandNotFound { terminal_id: u32, command: String },

    #[error("Client {client_id} is too slow to handle incoming messages")]
    ClientTooSlow { client_id: u16 },

    #[error("an error occured")]
    GenericError { source: anyhow::Error },
}

thiserror(ライブラリ向けエラー型定義)とanyhow(アプリケーション向けエラー伝播)の併用だ。

GenericErrorのフィールドがanyhow::Errorになっている点に注目してほしい。これにより、詳細なエラー型を定義したいケースと、「とりあえずエラーを伝播したい」ケースを両立できる。

その他の興味深い実装パターン

ここまでで主要なパターンを見てきたが、ソースコードを読み進める中で発見した「細かいが面白い」実装を紹介する。

スクロールバックの「Canonical Rows」アーキテクチャ

ターミナルの行は、表示上は複数行でも論理的には1行であることがある。長いコマンドが折り返されるケースだ。Zellijはこれを「Canonical Row」という概念で管理している。

zellij-server/src/panes/grid.rsを開く。

pub struct Row {
    pub columns: VecDeque<TerminalCharacter>,
    pub is_canonical: bool,  // 本当の改行か、折り返しか
    width: Option<usize>,    // キャッシュされた幅
}

is_canonicalフラグが鍵だ。trueなら本当の改行(ユーザーがEnterを押した)、falseなら表示上の折り返し。

スクロール時、折り返された行は元の「親」行と一緒に移動する必要がある。from_rowsメソッドで複数行をマージし、split_to_rows_of_lengthで再分割する。

pub fn split_to_rows_of_length(&mut self, max_row_length: usize) -> Vec<Row> {
    let mut parts: Vec<Row> = vec![];
    let mut current_part: VecDeque<TerminalCharacter> = VecDeque::new();
    let mut current_part_len = 0;
    for character in self.columns.drain(..) {
        if current_part_len + character.width() > max_row_length {
            parts.push(Row::from_columns(current_part));
            current_part = VecDeque::new();
            current_part_len = 0;
        }
        current_part_len += character.width();
        current_part.push_back(character);
    }
    // canonical statusを保持
    parts
}

文字の幅を考慮している点に注目。絵文字(幅2)の途中で行を切ると表示が崩れる。character.width()Unicode幅を取得し、正しい位置で分割している。

スクロールバックは3つのバッファで管理される。

pub(crate) lines_above: VecDeque<Row>,    // スクロールバック(上)
pub(crate) viewport: Vec<Row>,             // 表示中
pub(crate) lines_below: Vec<Row>,          // 未表示(下)

lines_aboveには上限がある。無限にスクロールバックを溜めるとメモリを食い尽くす。

fn bounded_push(vec: &mut VecDeque<Row>, sixel_grid: &mut SixelGrid, value: Row) -> Option<usize> {
    if vec.len() >= *SCROLL_BUFFER_SIZE.get().unwrap() {
        let line = vec.pop_front();
        if let Some(line) = line {
            sixel_grid.offset_grid_top();  // Sixel画像の位置も更新
        }
    }
    vec.push_back(value);
}

古い行を削除するとき、Sixel画像の位置も調整している。画像は行番号で位置を管理しているため、行が消えると座標がずれる。この連携が面白い。

幅を考慮したカーソル位置計算

絵文字やCJK文字は幅2を持つ。カーソルがその「途中」にあるケースをどう扱うか。

pub fn absolute_character_index_and_position_in_char(&self, x: usize) -> (usize, usize) {
    // xの幅を考慮したインデックスと、ワイド文字内の位置を返す
    let mut accumulated_width = 0;
    let mut absolute_index = x;
    let mut position_inside_character = 0;
    for (i, terminal_character) in self.columns.iter().enumerate() {
        accumulated_width += terminal_character.width();
        absolute_index = i;
        if accumulated_width > x {
            let character_start_position = accumulated_width - terminal_character.width();
            position_inside_character = x - character_start_position;
            break;
        }
    }
    (absolute_index, position_inside_character)
}

2つの値を返すのがポイント。「何番目の文字か」と「その文字内のどの位置か」。カーソルが絵文字の右半分にあるとき、position_inside_characterは1になる。これにより、カーソル移動やテキスト選択が正しく動作する。

OnceCellによるグローバル非同期ランタイム

複数スレッドから同じTokioランタイムを使いたい。Mutexで包むと毎回ロックが必要になる。

zellij-server/src/global_async_runtime.rsを開く。

use once_cell::sync::OnceCell;
use tokio::runtime::Runtime;

static TOKIO_RUNTIME: OnceCell<Runtime> = OnceCell::new();

pub fn get_tokio_runtime() -> &'static Runtime {
    TOKIO_RUNTIME.get_or_init(|| {
        tokio::runtime::Builder::new_multi_thread()
            .worker_threads(4)
            .thread_name("async-runtime")
            .enable_all()
            .build()
            .expect("Failed to create tokio runtime")
    })
}

OnceCellは初期化後はロック不要だ。最初の呼び出しでランタイムを作成し、以降は&'static Runtimeを返す。lazy_static!より明示的で、Arc<Mutex<>>より効率的。

4ワーカースレッドという設定も興味深い。Zellijは6つの主要スレッドを持つが、非同期タスク用には4スレッドで十分と判断したのだろう。

フローティングペインのZ-index管理

フローティングペインは重なり合う。どのペインが「上」にあるかを管理する必要がある。

zellij-server/src/panes/floating_panes/mod.rsを開く。

pub struct FloatingPanes {
    panes: BTreeMap<PaneId, Box<dyn Pane>>,
    z_indices: Vec<PaneId>,  // レンダリング順序
    pane_being_moved_with_mouse: Option<(PaneId, Position)>,
    // 多くのRc<RefCell<>>フィールド
}

pub fn stack(&self) -> Option<FloatingPanesStack> {
    if self.panes_are_visible() {
        let layers: Vec<PaneGeom> = self
            .z_indices
            .iter()
            .filter_map(|pane_id| self.panes.get(pane_id).map(|p| p.position_and_size()))
            .collect();
        // レンダリング順でレイヤーを返す
    }
}

z_indicesはVecだ。最後の要素が最前面。ペインをクリックすると、そのIDが末尾に移動する。

pub fn move_pane_to_front(&mut self, pane_id: &PaneId) {
    if let Some(index) = self.z_indices.iter().position(|id| id == pane_id) {
        self.z_indices.remove(index);
        self.z_indices.push(*pane_id);
    }
}

マウスイベントは逆順で処理される。最前面のペインから順にヒットテストし、最初にマッチしたペインがイベントを受け取る。これにより、重なったペインの下にあるペインはクリックを受け取らない。

Rc<RefCell<>>の多用も特徴的だ。display_areaviewportは複数のペインで共有される。Rustの所有権モデルでは、これを表現するのにRc<RefCell<>>が必要になる。

プラグインホットリロードのデバウンス

プラグインファイルが変更されたら自動で再読み込みしたい。しかし、ファイル保存時には複数のイベントが発火することがある。

zellij-server/src/plugins/watch_filesystem.rsを開く。

pub fn watch_filesystem(
    senders: ThreadSenders,
    zellij_cwd: &Path,
) -> Result<Debouncer<RecommendedWatcher, FileIdMap>> {
    let mut debouncer = new_debouncer(
        Duration::from_millis(DEBOUNCE_DURATION_MS),  // 400ms
        None,
        move |result: DebounceEventResult| match result {
            Ok(events) => {
                let mut create_events = vec![];
                let mut read_events = vec![];
                let mut update_events = vec![];
                let mut delete_events = vec![];
                // イベントを分類してプラグイン更新命令を送信
            }
        }
    )
}

400msのデバウンス。この値はDoherty Thresholdと同じだ。ファイルを保存すると、OSによっては「作成→書き込み→クローズ」と複数イベントが発火する。400ms待つことで、これらを1つのイベントにまとめる。

イベントは4種類に分類される。createreadupdatedeleteプラグインの更新にはこの区別が重要だ。新規作成と更新では、ロードの方法が異なる可能性がある。

アクション完了の追跡とタイムアウト

長時間実行されるアクションの完了をどう待つか。

zellij-server/src/route.rsを開く。

pub fn wait_for_action_completion(
    receiver: oneshot::Receiver<ActionCompletionResult>,
    action_name: &str,
    wait_forever: bool,
) -> ActionCompletionResult {
    let runtime = get_tokio_runtime();
    if wait_forever {
        runtime.block_on(async {
            match receiver.await { /* ... */ }
        })
    } else {
        match runtime.block_on(async {
            tokio::time::timeout(ACTION_COMPLETION_TIMEOUT, receiver).await
        }) {
            Ok(Ok(result)) => result,
            Err(_) | Ok(Err(_)) => {
                log::error!("Action {} did not complete within timeout", action_name);
                ActionCompletionResult { exit_status: None, affected_pane_id: None }
            }
        }
    }
}

oneshot + tokio::time::timeoutの組み合わせが優雅だ。oneshot::channelは一度だけ値を送信できるチャネル。アクション完了時に結果を送り、待機側はtimeoutでラップして無限待機を避ける。

スレッドをスピンさせたり、タイマーを別途管理したりする必要がない。Tokioのエコシステムをうまく活用している。

おわりに

冒頭で、何も起きていないターミナルを眺めていた話をした。

cat huge_log_file.logで200万行を流し込んでも、Zellijは固まらない。境界付きチャネルのバッファが満杯になれば、送信側が自動的にブロックされる。シンプルだが効果的だ。その仕組みを、後編では中から見てきた。

この記事を書きながら気づいたことがある。持ち帰れるパターンは、少なくない。

  1. crossbeamの境界付きチャネル: 無制限のチャネルはメモリを食い尽くす可能性がある。バッファサイズを明示的に制限することで、自然なバックプレッシャーが機能する

  2. 読み取り/書き込みスレッドの分離: PTYやソケットを扱うとき、同じスレッドで読み書きするとデッドロックの危険がある。Zellijのようにスレッドを分けると安全だ

  3. compile-time assertionによるメモリサイズ保証: const _: [(); 16] = [(); std::mem::size_of::<T>()];で、将来の変更でサイズが増えることを防げる

  4. シグナル送信のリトライ戦略: SIGTERM 3回 → SIGKILLというパターンは、プロセス管理の定石として覚えておきたい

  5. KDL形式のセッション永続化: シリアライズ形式と設定形式を統一するアイデアは、CLIツールやエディタの開発に応用できる

  6. OnceCellによるグローバルリソース管理: 複数スレッドから共有リソースにアクセスするとき、OnceCellなら初期化後のロックが不要だ

  7. oneshot + timeoutによる非同期待機: 長時間実行されるアクションの完了を待つとき、タイムアウト付きで待機するパターンは汎用的に使える

  8. Canonical Rowsによる論理行管理: テキストエディタやターミナルを作るなら、「表示上の行」と「論理的な行」を区別する必要がある

ただ、正直に言うと、これらのパターンを自分のプロジェクトに導入できるかどうかは分からない。境界付きチャネルのバッファサイズ50は、Zellijの6スレッド構成に最適化された値だ。自分のプロジェクトでは違う値が正解かもしれない。たぶん、違う。

「パターンを知っている」と「パターンを使いこなせる」の間には溝がある。その溝がどれくらい深いのか、ソースコードを読んだだけでは分からない。書いてみるしかない。書いて、つまずいて、またソースコードに戻る。そういうことの繰り返しなのだと思う。

ただ、Zellijのソースコードを2本の記事にわたって読んできて、1つ確信したことがある。Rustでマルチスレッドアプリケーションを書くとき、最も重要なのは「どのスレッドが何を所有するか」の設計だ。Zellijの6スレッド構成は、この所有権の設計が明確だから成立している。読み取りスレッドはPTYのReadを所有し、書き込みスレッドはWriteを所有する。この分離がデッドロックを防ぎ、境界付きチャネルがバックプレッシャーを実現し、差分レンダリングが性能を出す。パターンの根底にあるのは、結局のところ所有権モデルだ。

冒頭で眺めていた3つのペインを、今もう一度見る。左のVim、右上のテスト、右下のシェル。見え方が少し変わっている。PTYが3つ、境界付きチャネルがバッファサイズ50で繋がり、VTEパーサが毎秒数千バイトを解釈している。何も起きていないように見えるターミナルが、少しだけ騒がしく感じる。

Zellijのソースコードを読みたいなら、以下のファイルが特に参考になる。

PTY関連:

  • zellij-server/src/os_input_output.rs - PTY作成、シグナル、リサイズ(1035行)
  • zellij-server/src/pty.rs - PTYマネージャースレッド(2100行以上)
  • zellij-server/src/terminal_bytes.rs - 非同期読み取り(110行)
  • zellij-server/src/pty_writer.rs - 書き込みスレッド(89行)

レンダリング関連:

  • zellij-server/src/panes/terminal_character.rs - メモリ効率化とcompile-time assertion
  • zellij-server/src/panes/grid.rs - VTEパーサ、マウスイベント(4000行以上)
  • zellij-server/src/output/mod.rs - 差分レンダリング

非同期・スレッド関連:

  • zellij-server/src/global_async_runtime.rs - OnceCellによるTokioランタイム共有(17行)
  • zellij-server/src/route.rs - アクション完了追跡とタイムアウト
  • zellij-utils/src/channels.rs - エラーコンテキスト付きチャネル

ペイン管理:

  • zellij-server/src/panes/floating_panes/mod.rs - フローティングペインとZ-index
  • zellij-server/src/panes/tiled_panes/pane_resizer.rs - Cassowary制約ソルバー

その他:

  • zellij-utils/src/session_serialization.rs - KDL形式のセッション永続化
  • zellij-server/src/plugins/watch_filesystem.rs - プラグインホットリロード
  • zellij-server/src/panes/search.rs - 折り返し行を跨ぐ検索
git clone https://github.com/zellij-org/zellij.git
# 境界付きチャネルの実装
cat zellij-utils/src/channels.rs
# バッファサイズ50の使用箇所
grep -r "bounded(50)" zellij-*/src/

tmuxの安定性に満足しているなら、無理にZellijに乗り換える必要はない。しかし、Rustでマルチスレッドアプリケーションを書くなら、Zellijのソースコードは一読の価値がある。30年の歴史を持つtmuxとは異なるアプローチで、同等以上のパフォーマンスを達成しようとする試み——その設計判断から学べることは多い。

参考リンク

Zellij本体

github.com

zellij.dev

WASMランタイム移行(PR #4449)

github.com

使用しているクレート

github.com

github.com

github.com

github.com

KDL(設定ファイル形式)

kdl.dev

参考記事

zellij.dev

zellij.dev

https://zellij.dev/news/new-plugin-api/zellij.dev

https://zellij.dev/news/sixel-images-in-the-terminal/zellij.dev

パフォーマンス最適化

poor.dev

境界付きチャネル導入の背景

github.com

Doherty Threshold

lawsofux.com

関連技術

protobuf.dev

docs.rs

docs.rs

関連プロジェクト

github.com

github.com




以上の内容はhttps://syu-m-5151.hatenablog.com/entry/2026/01/29/092003より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14