はじめに
ターミナルで cat huge_log_file.log を実行した。画面が滝のように流れ始めた。Ctrl+Cを連打した。反応しない。画面はまだ流れている。椅子の背もたれに体を預けて、流れが止まるのを待った。Ciscoルーターの話もしたいが、それを始めるとどこまでも終わらなくなるので、ここでは話をしない。
こういう場面に出くわすことがある。自分が打ったコマンドなのに、自分では止められない。出力が止まったあと、何事もなかったかのように次のコマンドを打つ。たぶん、みんなそうしている。自分もそうしてきた。そうしてきたのだが、ある日ふと気になった。この暴走を、ソフトウェアはどうやって止めているのだろう。
Zellijは、ターミナルマルチプレクサだ。1つのターミナル画面を複数に分割し、複数のシェルを同時に操作できる。tmuxやscreenの現代版として、2021年にRustで開発が始まった。
本記事は2部構成の前編にあたる。前編では設計パターンを抽出し、後編ではさらに深く実装の内部に入る。読んで「なるほど」と思って、そのまま忘れる。たぶんそうなる。それでいい。合わなければ途中で離脱してもらって構わない。
約10万行のコードベースから、設計が優れている箇所——と、正直「これでいいのか?」と思う箇所——を抜き出して紹介する。他人のコードを読んで「分かった」と言い切れる自信はない。分からないまま書いている部分もある。それでも、書くことにした。
このブログが良ければ読者になったり、nwiizoのXやGithubをフォローしてくれると嬉しいです。
なぜZellijのコードを読むのか
ターミナルマルチプレクサのコードを読む機会はそう多くない。しかし、Zellijには以下の理由で読む価値がある。
- 実践的な並行処理: 複数のスレッドが協調して動く仕組みが、教科書的ではなく「本当に動くコード」として見られる
- WASMプラグインシステム: ブラウザ以外でWebAssemblyを使う実例として参考になる
- エラー処理の設計: 「このエラーは無視していい」「このエラーは致命的」を型で表現するパターンが秀逸
コードを読み始める前に、Zellijが前提としている概念をいくつか整理しておく。知っている人は読み飛ばしてもらっていい。
前提知識
Zellijのコードを読み進める前に、いくつかの概念を押さえておくと理解が早い。正直に言えば、自分もこれらを「完全に理解している」とは言い難い。使ったことはある。使ったことはあるが、説明しろと言われると手が止まる。そういう概念を、改めて整理しておく。
擬似端末(PTY)
ターミナルマルチプレクサの根幹技術だ。PTY(Pseudo Terminal)は、物理的なターミナル装置をソフトウェアでエミュレートする仕組み。マスター側とスレーブ側のペアで構成され、マスター側がZellijのようなプログラム、スレーブ側がシェル(bashやzsh)になる。シェルは自分が本物のターミナルに接続されていると思い込んでいるが、実際にはZellijが間に入ってデータを仲介している。
MPSCチャネル
Rustの標準ライブラリにあるstd::sync::mpscは「Multiple Producer, Single Consumer」の略だ。複数の送信者から1つの受信者にメッセージを送れる。Zellijでは各スレッドがこのチャネルでメッセージをやり取りしている。crossbeam-channelというクレートを使うとMPMC(Multiple Producer, Multiple Consumer)も実現できるが、Zellijは基本的にMPSCで設計されている。
Actorモデル
各スレッドを独立した「アクター」として扱い、共有メモリではなくメッセージパッシングで通信するパターン。ZellijのScreenThreadやPtyThreadはそれぞれがアクターとして振る舞い、enumで定義された命令(ScreenInstructionなど)を受け取って処理する。ロックの競合を避けやすく、デバッグもしやすい。
WebAssembly(WASM)
ブラウザで動くバイナリフォーマットとして生まれたが、サーバーサイドやCLIツールでも使われるようになった。Zellijはプラグインの実行環境としてWASMを採用している。プラグインがクラッシュしても本体には影響しない、言語に依存しない、といった利点がある。Zellijは当初wasmtimeをランタイムとして使用していたが、現在はwasmiに移行している。詳細は後述する。
では、コードを見ていこう。ここから先は長い。覚悟してほしい——と言いたいところだが、自分も書きながら覚悟している。
Cargo Workspace構成:最初に見るべきファイル
ソースコードを読むとき、私はまずCargo.tomlを開く。プロジェクトの全体像が分かるからだ。
Zellijのルートディレクトリでlsすると、以下の構造が見える。
zellij/ ├── zellij/ # エントリーポイント ├── zellij-client/ # クライアント側 ├── zellij-server/ # サーバー側 ├── zellij-utils/ # 共有ユーティリティ ├── zellij-tile/ # プラグインSDK ├── default-plugins/ # 標準プラグイン └── xtask/ # ビルドタスク
Zellijはクライアント・サーバー型のアーキテクチャを採用している。ターミナルの画面を表示する「クライアント」と、実際にシェルを動かす「サーバー」が別プロセスで動いている。
なぜわざわざ分離するのか?答えは「セッションの永続化」にある。SSH接続が切れても、サーバー側でシェルは動き続ける。後で再接続すれば、作業を途中から再開できる。リモートワークで長時間かかるビルドを実行中にネットワークが切れても、ビルドは止まらない。これがターミナルマルチプレクサの最大の利点だ。セッションの永続化がどのように実装されているかは、後半の「セッション永続化:KDLによるシリアライズ」で詳しく見る。
ワークスペース構成を把握したところで、次はビルドの仕組みを見てみよう。
xtask:Rustで書くビルドスクリプト
xtask/ディレクトリは、MakefileやシェルスクリプトをRustで置き換える「xtaskパターン」の実装だ。
// xtask/src/main.rs fn main() -> anyhow::Result<()> { let shell = &Shell::new()?; let flags = flags::Xtask::from_env()?; match flags.subcommand { flags::XtaskCmd::Build(flags) => build::build(shell, flags), flags::XtaskCmd::Clippy(flags) => clippy::clippy(shell, flags), flags::XtaskCmd::Format(flags) => format::format(shell, flags), flags::XtaskCmd::Test(flags) => test::test(shell, flags), flags::XtaskCmd::Dist(flags) => pipelines::dist(shell, flags), flags::XtaskCmd::Install(flags) => pipelines::install(shell, flags), // ... } }
.cargo/config.tomlにエイリアスを設定すると、cargo xtask buildのように呼び出せる。
[alias] xtask = "run --package xtask --"
xtaskパターンの利点は3つある。
- クロスプラットフォーム: シェルスクリプトはOS依存だが、Rustはどこでも動く
- 型安全:
xflagsクレートでCLI引数をパースし、typoをコンパイル時に検出 - 言語統一: ビルドスクリプトもRustで書けば、チーム全員が読める
pipelines.rsでは、複数のビルドステージを.and_then()でチェーンしている。
// xtask/src/pipelines.rs pub fn make(sh: &Shell, flags: flags::Make) -> anyhow::Result<()> { format::format(sh, flags::Format { check: false }) .and_then(|_| build::build(sh, build_flags)) .and_then(|_| test::test(sh, test_flags)) .and_then(|_| clippy::clippy(sh, flags::Clippy {})) .with_context(err_context) }
どこかでエラーが発生すれば、以降のステージはスキップされる。RustのResult型を活かしたパイプライン設計だ。
スレッド設計:thread_bus.rsを読む
zellij-server/src/thread_bus.rsを開くと、スレッド間通信の設計が見える。
// zellij-server/src/thread_bus.rs #[derive(Default, Clone)] pub struct ThreadSenders { pub to_screen: Option<SenderWithContext<ScreenInstruction>>, pub to_pty: Option<SenderWithContext<PtyInstruction>>, pub to_plugin: Option<SenderWithContext<PluginInstruction>>, pub to_server: Option<SenderWithContext<ServerInstruction>>, pub to_pty_writer: Option<SenderWithContext<PtyWriteInstruction>>, pub to_background_jobs: Option<SenderWithContext<BackgroundJob>>, pub should_silently_fail: bool, // テスト用 }
6つのスレッドへの送信チャネルを1つの構造体にまとめている。各スレッドが他のスレッドにメッセージを送りたいとき、このThreadSendersを経由する。
┌────────────────────────────────────────────────────────────────────┐ │ ZELLIJ SERVER │ │ ┌───────────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ SCREEN │ │ PTY │ │ PLUGIN │ │ PTY_WRITER │ │ │ │ THREAD │ │ THREAD │ │ THREAD │ │ THREAD │ │ │ │ │ │ │ │ │ │ │ │ │ │ タブ/ペイン │ │ 擬似端末 │ │ WASM │ │ 書き込み専用 │ │ │ │ 管理 │ │ 生成管理 │ │ ランタイム │ │ │ │ │ └───────────────┘ └──────────┘ └──────────────┘ └──────────────┘ │ │ ┌───────────────────┐ ┌──────────────────────────────────────┐ │ │ │ BACKGROUND_JOBS │ │ SERVER (IPC) │ │ │ │ THREAD │ │ THREAD │ │ │ └───────────────────┘ └──────────────────────────────────────┘ │ └────────────────────────────────────────────────────────────────────┘
Bus構造体も見ておこう。
pub(crate) struct Bus<T> { receivers: Vec<channels::Receiver<(T, ErrorContext)>>, pub senders: ThreadSenders, pub os_input: Option<Box<dyn ServerOsApi>>, }
receiversがVecになっている。なぜ1つの受信口ではなく、複数の受信口を持つ必要があるのか?
lib.rsを開いて、チャネルを作っている箇所を探すと、理由が分かる。
// zellij-server/src/lib.rs let (to_screen, screen_receiver): ChannelWithContext<ScreenInstruction> = channels::unbounded(); // 通常のメッセージ用(無制限) let (to_screen_bounded, bounded_screen_receiver): ChannelWithContext<ScreenInstruction> = channels::bounded(50); // PTYからの高速入力用(上限50個)
このbounded(50)は2022年6月のPR #1265で導入された。
Screenスレッドには2つのチャネルがある。通常の無制限チャネルと、上限50個の境界付きチャネル。
これは「バックプレッシャー」を実現するための設計だ。冒頭のcat huge_log_file.logを思い出してほしい。PTYからの出力が速すぎると、画面描画が追いつかない。上限付きチャネルを使うと、バッファが満杯になったときに送信側がブロックされる。
一方、ユーザー操作(ペインの移動、タブの切り替え)は無制限チャネル経由で送られ、即座に処理される。ユーザーがキーを押したのに反応しない、という事態は避けたいからだ。
前提知識で触れたMPSCチャネルとActorモデルが、ここで活きている。各スレッドは自分専用のチャネルからメッセージを受け取り、状態を外部と共有しない。共有しなければ、奪い合いは起きない。この設計により、Arc<Mutex<T>>のような共有ロックを使わずにスレッド間通信を実現している。デッドロックの心配がない。
SenderWithContext:エラー追跡付きチャネル
zellij-utils/src/channels.rsには、crossbeamチャネルのラッパーがある。
// zellij-utils/src/channels.rs pub type ChannelWithContext<T> = (Sender<(T, ErrorContext)>, Receiver<(T, ErrorContext)>); #[derive(Clone)] pub struct SenderWithContext<T> { sender: Sender<(T, ErrorContext)>, } impl<T: Clone> SenderWithContext<T> { pub fn send(&self, event: T) -> Result<(), SendError<(T, ErrorContext)>> { let err_ctx = get_current_ctx(); self.sender.send((event, err_ctx)) } }
メッセージを送るたびに、現在のエラーコンテキストが自動的に付与される。
get_current_ctx()は、thread-localストレージから現在の呼び出し履歴を取得する。これにより、エラーが発生したとき「どのスレッドの、どの処理から送られたメッセージか」を追跡できる。
// zellij-utils/src/errors.rs thread_local!( pub static OPENCALLS: RefCell<ErrorContext> = RefCell::default() ); // 非同期タスク用にはtask_localも用意 task_local! { pub static ASYNCOPENCALLS: RefCell<ErrorContext> = RefCell::default() }
スレッドごとに独立した呼び出し履歴を持ち、最大6階層まで記録する。
const MAX_THREAD_CALL_STACK: usize = 6; #[derive(Clone, Copy)] pub struct ErrorContext { calls: [ContextType; MAX_THREAD_CALL_STACK], }
エラーが起きると「Screen → HandlePtyBytes → Render」のような呼び出し履歴が出力される。マルチスレッドのデバッグでは、この情報がないと原因特定に時間がかかる。
100以上のバリアント:Instruction enum
screen.rsを開くと、巨大なenumが見つかる。
// zellij-server/src/screen.rs pub enum ScreenInstruction { PtyBytes(u32, VteBytes), PluginBytes(Vec<PluginRenderAsset>), Render, NewPane(PaneId, Option<InitialTitle>, HoldForCommand, ...), WriteCharacter(Option<KeyWithModifier>, Vec<u8>, bool, ClientId, ...), MoveFocusLeft(ClientId, Option<NotificationEnd>), MoveFocusRight(ClientId, Option<NotificationEnd>), ScrollUp(ClientId, Option<NotificationEnd>), // ... 約100個のバリアント }
100個以上のバリアント。正直、最初に見たときは「これ、本当に正しいのか?」と思った。
git履歴を追うと、初期のScreenInstructionは11バリアントしかなかった。Pty、Render、HorizontalSplit、VerticalSplit、WriteCharacterなど基本的なものだけだ。5年間で148バリアント以上に成長している。25倍。機能追加のたびにバリアントが増えていった結果だ。
利点はある。
文字列でメッセージを送る設計(例:"move_focus_left")と比べると、タイポをコンパイル時に検出できる点で優れている。
ただ、疑問も残る。100個のバリアントを持つenumに新しいメッセージを追加するとき、Fromトレイトの実装も更新しなければならない。忘れたらコンパイルエラーになるとはいえ、変更箇所が分散するのは保守コストだ。trait objectやdynamic dispatchで抽象化する選択肢もあったはずだが、Zellijはそれを選ばなかった。パフォーマンスを優先したのか、あるいは「enumで十分」という判断なのか。答えは分からない。
各Instruction enumには、FromトレイトでContextTypeへの変換が実装されている。
impl From<&ScreenInstruction> for ScreenContext { fn from(server_instruction: &ScreenInstruction) -> Self { match *server_instruction { ScreenInstruction::PtyBytes(..) => ScreenContext::HandlePtyBytes, ScreenInstruction::Render => ScreenContext::Render, ScreenInstruction::NewPane(..) => ScreenContext::NewPane, // ... 全バリアントを網羅 } } }
これにより、エラーコンテキストへの変換が自動化される。
WASMプラグイン:wasmiの採用
zellij-server/src/plugins/plugin_loader.rsを開くと、WASMランタイムのimportが見える。
// zellij-server/src/plugins/plugin_loader.rs use wasmi::{Engine, Instance, Linker, Module, Store, StoreLimits}; use wasmi_wasi::sync::WasiCtxBuilder; use wasmi_wasi::WasiCtx;
wasmiを使っている。Wasmtimeではない。
両者の違いを整理する。
| 項目 | Wasmtime | wasmi |
|---|---|---|
| 実行方式 | JITコンパイル | インタプリタ |
| 速度 | 高速 | 低速 |
| 攻撃面 | 広い(JITは複雑) | 狭い |
| 依存 | LLVM | ピュアRust |
実は、Zellijは当初Wasmtimeを使っていた。2025年10月のPR #4449「Migrate from wasmtime to wasmi」でwasmiに移行している。この移行と同時にPinnedExecutor(動的スレッドプール)が導入された。JITコンパイルをやめることで、プラグインごとにスレッドをピン留めする設計が可能になった。インタプリタ方式は遅いが、リソース管理の予測可能性とセキュリティで優れる。
zellij-tile/src/lib.rsにはプラグインSDKがある。
// zellij-tile/src/lib.rs pub trait ZellijPlugin: Default { fn load(&mut self, configuration: BTreeMap<String, String>) {} fn update(&mut self, event: Event) -> bool { false } // trueで再描画 fn pipe(&mut self, pipe_message: PipeMessage) -> bool { false } fn render(&mut self, rows: usize, cols: usize) {} }
プラグインは4つのメソッドを実装するだけでいい。
register_plugin!マクロの中身を見ると、3つの工夫がある。
#[macro_export] macro_rules! register_plugin { ($t:ty) => { thread_local! { static STATE: std::cell::RefCell<$t> = RefCell::new(Default::default()); } fn main() { std::panic::set_hook(Box::new(|info| { report_panic(info); })); } #[no_mangle] fn load() { STATE.with(|state| { let protobuf_bytes: Vec<u8> = $crate::shim::object_from_stdin().unwrap(); // ... }); } }; }
thread_local!: WASMは基本的に状態を持たない設計だが、これで状態を保持できる#[no_mangle]: 関数名をそのまま維持し、Zellijホストから呼び出せるようにする- Protocol Buffers: WASM境界を越えるデータはシリアライズする必要がある
プラグインの権限管理も見ておこう。default-plugins/status-bar/src/main.rsを開く。
fn load(&mut self, _configuration: BTreeMap<String, String>) { request_permission(&[PermissionType::ReadApplicationState]); subscribe(&[ EventType::TabUpdate, EventType::ModeUpdate, EventType::CopyToClipboard, EventType::SystemClipboardFailure, ]); }
request_permissionでプラグインが必要な権限を要求し、ユーザーが許可する。zellij-utils/src/data.rsには16種類の権限が定義されている。
PermissionType::ReadApplicationState // 状態の読み取り PermissionType::ChangeApplicationState // 状態の変更 PermissionType::RunCommands // コマンド実行 PermissionType::WebAccess // ネットワークアクセス PermissionType::FullHdAccess // ファイルシステムアクセス // ... 他11種類
AndroidやiOSの権限モデルと同様に、細粒度の制御ができる。
FatalError:エラーの重大度を型で表現
zellij-utils/src/errors.rsには、エラーの重大度を呼び出し側で選択できるトレイトがある。
pub trait FatalError<T> { fn non_fatal(self); // エラーをログに記録して続行 fn fatal(self) -> T; // アンラップまたはパニック } impl<T> FatalError<T> for anyhow::Result<T> { fn non_fatal(self) { if self.is_err() { discard_result(self.context("a non-fatal error occured").to_log()); } } fn fatal(self) -> T { if let Ok(val) = self { val } else { self.context("a fatal error occured") .expect("Program terminates") } } }
使用例を見ると、意図が明確になる。
// スレッドのエントリーポイント:失敗したらプロセス終了 move || pty_thread_main(pty, layout.clone()).fatal() // 内部のエラー処理:失敗してもログを出して続行 self.senders.send_to_screen(instruction).non_fatal();
unwrap()やexpect()では「なぜここでパニックしていいのか」が分からない。.fatal()なら意図が明確だ。コードレビューでも「このエラーは本当に致命的か?」という議論がしやすくなる。
ここまで読んで、自分のプロジェクトのエラー処理が急に心配になった。unwrap()を何箇所書いただろう。数えたくない。数えたくないが、たぶん数えるべきだ。
ここからは、Zellijの別の顔を見ていく。セッションの永続化、そしてターミナルの根幹であるPTY処理だ。
セッション永続化:KDLによるシリアライズ
zellij-utils/src/session_serialization.rsには、セッション状態をKDL形式で保存する処理がある。
// zellij-utils/src/session_serialization.rs #[derive(Default, Debug, Clone)] pub struct GlobalLayoutManifest { pub global_cwd: Option<PathBuf>, pub default_shell: Option<PathBuf>, pub default_layout: Box<Layout>, pub tabs: Vec<(String, TabLayoutManifest)>, } pub fn serialize_session_layout( global_layout_manifest: GlobalLayoutManifest, ) -> Result<(String, BTreeMap<String, String>), &'static str> { let mut document = KdlDocument::new(); let mut pane_contents = BTreeMap::new(); // ... }
KDL(KDL Document Language)は、人間が読みやすいように設計された設定言語だ。Zellijの設定ファイルにも使われている。
セッションの保存時に、レイアウト情報(KDL文字列)とペインの内容(BTreeMap)を分離して返すのは、関心の分離ができている。
PTY処理:os_input_output.rsの低レベルコード
前提知識で触れたPTY(擬似端末)が、実際にどう実装されているか。ターミナルマルチプレクサの核心部分を見る。冒頭の cat huge_log_file.log で画面が止まらなくなったあの現象——あれはPTYのmaster側から流れ込むバイト列を、Zellijがどう捌くかという問題だった。zellij-server/src/os_input_output.rsを開く。冒頭の cat huge_log_file.log で画面が暴走したとき、裏側ではここのコードが動いていた。あの滝が、ここで生まれている。
use nix::pty::{openpty, OpenptyResult, Winsize}; 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_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") }; // ... }
unsafeブロック、libc::login_tty、pre_exec。低レベルなUnixプログラミングだ。
openptyは「master」と「slave」という2つのファイルディスクリプタを作る。Zellijはmaster側を持ち、シェル(bashやzsh)はslave側を持つ。シェルが出力した文字はmaster側で読み取れる。
login_ttyは、Unix系OSでターミナルをセットアップする伝統的な関数だ。これにより、子プロセスはslave側のPTYを「自分の端末」として認識する。
terminal_bytes.rs:非同期I/O
terminal_bytes.rsは、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?; }, } } Ok(()) } }
64KBバッファ。一般的な8KBや4KBではなく、大きめのバッファを使っている。
このバッファサイズは2022年7月のPR #1585「perf(terminal): better responsiveness」で導入された。コミットメッセージには「only buffer terminal bytes when screen thread is backed up」とある。画面スレッドが詰まっているときだけバッファリングし、通常時は即座に転送する。64KBという数値は、1回のシステムコールで読み取れる量と、メモリ消費のバランスから選ばれたと思われる。
Ok(0)とErrの区別も重要。Ok(0)はファイル終端(プロセスが終了した)、Errは本当のエラー。この区別を間違えると、プロセス終了時にエラーログが出てしまう。
64KBのバッファが、あの画面の暴走を受け止めている。自分が cat を打って椅子にもたれかかっていたあの数秒間、このコードが黙々とバイトを読んでいた。なんだか少し申し訳ない気持ちになる。
grid.rs:ANSIエスケープシーケンスの処理
PTYから読み取ったバイト列は、そのまま画面に表示できるわけではない。ターミナルに表示される色付きの文字や、カーソルの移動は「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) { // 通常文字の表示 } fn execute(&mut self, byte: u8) { // 制御文字(\n, \r, \t等) } fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], ignore: bool, action: char) { // CSIシーケンス: カーソル移動、色変更など } fn osc_dispatch(&mut self, params: &[&[u8]], bell_terminated: bool) { // OSCシーケンス: ウィンドウタイトル設定など } }
Performトレイトを実装するだけで、vteがパースした結果を受け取れる。ANSIエスケープシーケンスの仕様は複雑で、エッジケースも多い。自作するより、実績のあるクレートを使う方が合理的だ。
差分レンダリング:output/mod.rs
全画面を毎回再描画すると遅い。zellij-server/src/output/mod.rsを見ると、変更された行だけを追跡している。
pub struct OutputBuffer { pub changed_lines: HashSet<usize>, // 変更された行インデックス pub should_update_all_lines: bool, } 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 update_all_lines(&mut self) { self.clear(); self.should_update_all_lines = true; } }
HashSetを使うことで、同じ行が複数回更新されても重複エントリが発生しない。
should_update_all_linesフラグは、ウィンドウサイズが変わったときなど、全画面を再描画する必要があるケースに対応している。
terminal_character.rs:メモリ効率の工夫
zellij-server/src/panes/terminal_character.rsには、メモリ効率を意識したパターンがある。
pub const EMPTY_TERMINAL_CHARACTER: TerminalCharacter = TerminalCharacter { character: ' ', width: 1, styles: RcCharacterStyles::Reset, }; thread_local! { static RC_DEFAULT_STYLES: RcCharacterStyles = RcCharacterStyles::Rc(Rc::new(DEFAULT_STYLES)); }
constでデフォルト値を定義し、thread_local!でスタイルオブジェクトをキャッシュしている。ターミナルの各セルにスタイル情報を持たせると、同じスタイルのオブジェクトが大量に生成される。参照カウントでキャッシュすることで、メモリ使用量を削減できる。
data.rs:deriveの活用
zellij-utils/src/data.rsには、様々なenumが定義されている。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, EnumIter)] pub enum InputMode { Normal, Locked, Resize, Pane, Tab, Scroll, EnterSearch, Search, RenameTab, RenamePane, Session, Move, Prompt, Tmux, }
#[derive(...)]に9つのトレイトを並べている。特にEnumIter(strumクレート)が便利で、InputMode::iter()で全バリアントを列挙できる。UIの選択肢一覧を作るときに使える。
Event enumには#[non_exhaustive]属性がついている。
#[non_exhaustive] pub enum Event { ModeUpdate(ModeInfo), TabUpdate(Vec<TabInfo>), Key(KeyWithModifier), // ... 約30バリアント }
これは「このenumにはまだバリアントが追加される可能性がある」という宣言だ。外部のプラグイン開発者は必ず_ => ()アームを書く必要がある。
fn update(&mut self, event: Event) -> bool { match event { Event::Key(key) => { /* ... */ } Event::ModeUpdate(mode_info) => { /* ... */ } _ => () // non_exhaustiveのため必須 } }
これにより、Zellijがバージョンアップで新しいイベントを追加しても、既存のプラグインがコンパイルエラーにならない。後方互換性を保つための工夫だ。
キーバインディング:モード別マッピングと例外処理
zellij-utils/src/input/keybinds.rsには、キーバインディングの管理ロジックがある。
// zellij-utils/src/input/keybinds.rs #[derive(Clone, PartialEq, Deserialize, Serialize, Default)] pub struct Keybinds(pub HashMap<InputMode, HashMap<KeyWithModifier, Vec<Action>>>);
モードごとにキーマップを持つ設計だ。InputMode(Normal, Locked, Resize等)をキーに、さらにキーとアクションのマップを値に持つ。
注目すべきはhandle_ctrl_jという関数だ。
fn handle_ctrl_j( mode_keybindings: &HashMap<KeyWithModifier, Vec<Action>>, raw_bytes: &[u8], key_is_kitty_protocol: bool, ) -> Option<Vec<Action>> { let ctrl_j = KeyWithModifier::new(BareKey::Char('j')).with_ctrl_modifier(); if mode_keybindings.get(&ctrl_j).is_some() { mode_keybindings.get(&ctrl_j).cloned() } else { Some(vec![Action::Write { /* ... */ }]) } }
Ctrl-Jはbyte [10]を送信するが、これはEnterキーと同じバイト列だ。ターミナルの歴史的な事情により、この2つを区別する必要がある。Zellijは「Ctrl-Jにバインドがあればそれを実行、なければ生のバイトを送信」という戦略を取っている。
こういうエッジケースは、ターミナルソフトウェアを書くときに避けて通れない。コードを読むまで気づかなかった。
設定マージ:Option型の活用
zellij-utils/src/input/options.rsには、設定値のマージロジックがある。
// zellij-utils/src/input/options.rs pub fn merge(&self, other: Options) -> Options { let mouse_mode = other.mouse_mode.or(self.mouse_mode); let pane_frames = other.pane_frames.or(self.pane_frames); let default_mode = other.default_mode.or(self.default_mode); let default_shell = other.default_shell.or_else(|| self.default_shell.clone()); // ... 約30フィールド }
Option::orとOption::or_elseを使った設定マージだ。other(後から来た設定)に値があればそれを使い、なければself(既存の設定)を使う。
orとor_elseの使い分けにも注目。
or:Copyトレイトを実装している型(bool、InputMode等)or_else:Cloneが必要な型(PathBuf、String等)
or_elseはクロージャを取るので、Cloneのコストは必要なときだけ発生する。30フィールド以上ある設定を毎回全部Cloneすると無駄だ。
この設計により、「デフォルト設定 → 設定ファイル → CLI引数」という3段階のマージが自然に実現できる。
// zellij-utils/src/input/config.rs pub fn merge(&mut self, other: Config) -> Result<(), ConfigError> { self.options = self.options.merge(other.options); self.keybinds.merge(other.keybinds.clone()); self.themes = self.themes.merge(other.themes); self.plugins.merge(other.plugins); // ... }
各フィールドが独自のmergeメソッドを持ち、親構造体は単にそれを呼び出すだけ。責任が分散している。
OnceLock:実行時に決まる設定値
zellij-utils/src/consts.rsには、定数と「起動時に一度だけ設定される値」が混在している。
// zellij-utils/src/consts.rs pub const DEFAULT_SCROLL_BUFFER_SIZE: usize = 10_000; pub static SCROLL_BUFFER_SIZE: OnceLock<usize> = OnceLock::new(); pub static DEBUG_MODE: OnceLock<bool> = OnceLock::new();
constとstatic OnceLockの使い分けに注目してほしい。
OnceLockはlazy_static!の後継で、Rust 1.70で標準ライブラリに入った。初期化のタイミングを明示的に制御できる点が違う。
// zellij-server/src/lib.rs での初期化 SCROLL_BUFFER_SIZE.get_or_init(|| { config.scroll_buffer_size.unwrap_or(DEFAULT_SCROLL_BUFFER_SIZE) });
get_or_initは「まだ初期化されていなければ初期化する」という意味だ。2回目以降の呼び出しは、最初に設定された値を返す。
この値はpanes/grid.rsで使われる。
// zellij-server/src/panes/grid.rs fn bounded_push(vec: &mut VecDeque<Row>, sixel_grid: &mut SixelGrid, value: Row) -> Option<usize> { let mut dropped_line_width = None; if vec.len() >= *SCROLL_BUFFER_SIZE.get().unwrap() { let line = vec.pop_front(); // 古い行を削除 if let Some(line) = line { sixel_grid.offset_grid_top(); // 画像グリッドも調整 dropped_line_width = Some(line.width()); } } vec.push_back(value); dropped_line_width }
スクロールバッファが上限(デフォルト10,000行)に達すると、古い行がFIFOで削除される。sixel_grid.offset_grid_top()は、ターミナル内の画像表示(Sixel形式)の位置調整だ。テキストと画像が混在するターミナルでは、こういう細かい調整が必要になる。
PinnedExecutor:プラグイン用の動的スレッドプール
zellij-server/src/plugins/pinned_executor.rsには、プラグイン実行用の独自スレッドプールがある。1300行以上のファイルだ。
// zellij-server/src/plugins/pinned_executor.rs /// A dynamic thread pool that pins jobs to specific threads based on plugin_id /// Starts with 1 thread and expands when threads are busy, shrinks when plugins unload pub struct PinnedExecutor { // Sparse vector - Some(thread) for active threads, None for removed threads execution_threads: Arc<Mutex<Vec<Option<ExecutionThread>>>>, // Maps plugin_id -> thread_index (permanent assignment) plugin_assignments: Arc<Mutex<HashMap<u32, usize>>>, // Maps thread_index -> set of plugin_ids assigned to it thread_plugins: Arc<Mutex<HashMap<usize, HashSet<u32>>>>, // Next thread index to use when spawning (monotonically increasing) next_thread_idx: AtomicUsize, max_threads: usize, // ... }
各プラグインを特定のスレッドに「ピン留め」する設計だ。プラグインAは常にスレッド1で、プラグインBは常にスレッド2で実行される。スレッド間でプラグインが移動しない。
なぜこの設計なのか?WASMのインスタンスはスレッドセーフではない。同じプラグインを複数のスレッドから同時に呼び出すと壊れる。ピン留めすれば、この問題を構造的に回避できる。
スレッドの割り当てロジックも見てみよう。
pub fn register_plugin(&self, plugin_id: u32) -> usize { // ... // Find a non-busy thread with assigned plugins (prefer reusing threads) let mut best_thread: Option<(usize, usize)> = None; // (index, load) for (idx, thread_opt) in threads.iter().enumerate() { if let Some(thread) = thread_opt { let is_busy = thread.jobs_in_flight.load(Ordering::SeqCst) > 0; if !is_busy { let load = thread_plugins.get(&idx).map(|s| s.len()).unwrap_or(0); if best_thread.is_none() || best_thread.map(|b| load < b.1).unwrap_or(false) { best_thread = Some((idx, load)); } } } } // ... }
「最も負荷が低い非ビジースレッド」を選ぶ。jobs_in_flight(実行中のジョブ数)が0のスレッドの中から、割り当て済みプラグイン数が最小のものを選ぶ。
すべてのスレッドがビジーで、かつmax_threadsに達していない場合は、新しいスレッドを生成する。逆に、プラグインがアンロードされてスレッドが空になると、そのスレッドは縮退する(Noneに置き換えられる)。
この「動的に拡縮するスレッドプール」は、プラグインの数が事前に分からないシステムでは合理的だ。固定スレッド数だと、プラグインが少ないときにリソースを無駄にし、多いときにボトルネックになる。自分だったら固定スレッド数で妥協していたかもしれない。「動的に拡縮」と言うのは簡単だが、縮退の判断を正しく実装する自信は、正直ない。
#[track_caller]:エラー発生箇所を追跡する
zellij-utils/src/errors.rsには、#[track_caller]属性を使った工夫がある。
// zellij-utils/src/errors.rs pub trait LoggableError<T>: Sized { #[track_caller] fn print_error<F: Fn(&str)>(self, fun: F) -> Self; #[track_caller] fn to_log(self) -> Self { let caller = std::panic::Location::caller(); self.print_error(|msg| { log::logger().log( &log::Record::builder() .level(log::Level::Error) .args(format_args!("{}", msg)) .file(Some(caller.file())) .line(Some(caller.line())) .module_path(None) .build(), ); }) } // ... }
#[track_caller]は、関数の呼び出し元の位置情報を取得できるようにする属性だ。これがないと、エラーログに出力されるのはerrors.rsの行番号になってしまう。#[track_caller]を付けることで、実際にエラーが発生した場所の行番号がログに出る。
ファイルのコメントにも説明がある。
// NOTE: The log entry has no module path associated with it. This is because `log` // gets the module path from the `std::module_path!()` macro, which is replaced at // compile time in the location it is written!
module_path!()マクロはコンパイル時に展開されるため、errors.rsのモジュールパスになってしまう。そこで、モジュールパスは諦めてNoneにし、ファイルパスと行番号だけを保持している。完璧ではないが、デバッグには十分だ。
機能と実装の対応表
ここまで読んできた内容を、「機能」と「実装」の対応で整理する。
| 機能 | 実装方法 | 関連ファイル |
|---|---|---|
| セッション永続化 | クライアント・サーバー分離 | zellij-client/, zellij-server/ |
| ビルドタスク | xtaskパターン | xtask/ |
| 大量出力時のメモリ保護 | 境界付きチャネル | lib.rsのchannels::bounded(50) |
| スレッド間通信 | メッセージパッシング + ThreadSenders | thread_bus.rs, channels.rs |
| エラー追跡 | SenderWithContext + thread-local | channels.rs, errors.rs |
| プラグインサンドボックス | WebAssembly(wasmi) | plugins/plugin_loader.rs |
| プラグイン権限制御 | 16種類のPermissionType | data.rs |
| プラグイン実行 | PinnedExecutor(動的スレッドプール) | plugins/pinned_executor.rs |
| エラーの重大度 | FatalError/non_fatalトレイト | errors.rs |
| エラー発生箇所の追跡 | #[track_caller] + Location | errors.rs |
| 実行時設定値 | OnceLock(スクロールバッファサイズ等) | consts.rs |
| キーバインディング | モード別HashMap + 例外処理 | input/keybinds.rs |
| 設定マージ | Option::or/or_else による多層マージ | input/options.rs, input/config.rs |
| ターミナル出力の解析 | vteクレート | panes/grid.rs |
| 描画最適化 | 差分レンダリング(HashSet) | output/mod.rs |
| PTYの生成と制御 | nixクレート + login_tty | os_input_output.rs |
| 非同期I/O | async-std | terminal_bytes.rs |
| 設定・レイアウト保存 | KDL形式 | session_serialization.rs |
おわりに
冒頭の cat huge_log_file.log の話に戻る。あの暴走を、ソフトウェアはどうやって止めているのか。答えは「50個のメッセージで満杯になるチャネル」だった。PTYからの出力が速すぎれば、バッファが満杯になり、送信側が自動的にブロックされる。それだけだ。それだけのことが、あの滝を止めている。
10万行のコードを読んで見えてきたのは、たぶん「当たり前のことを愚直にやっている」ということだった気がする。
スレッド間で状態を共有しない。教科書に書いてあることだ。書いてあるが、実際のプロジェクトでは「ちょっとだけ共有したい」という誘惑がある。ZellijはThreadSenders構造体でチャネルの送信側だけを共有し、状態は各スレッドが排他的に所有する。知っていることと、守れることは違う。それは自分に言い聞かせている。
メッセージは型安全なenumで表現する。ScreenInstructionは100以上のバリアントを持つ。100個のバリアントを書く勇気。それがZellijの強さかもしれないし、将来の負債かもしれない。たぶん、両方だ。
自分のプロジェクトに何を持ち帰れるのか。考えてみた。手が止まった。意外と出てこない。10万行を読んで「すごい」と思ったが、「じゃあ自分は何をするのか」という問いの前では言葉に詰まる。それでも、絞り出してみる。
crossbeamの境界付きチャネル。無制限のチャネルはメモリを食い尽くす。バッファサイズを明示的に制限することで、自然なバックプレッシャーが機能する。これは明日から使える。たぶん。
FatalError/non_fatalパターン。
unwrap()を見たら「なぜここでパニックしていいのか」を問う。その問いに答える設計がFatalErrorだ。自分のコードに入れたら、半分以上のunwrap()が正当化できない気がする。それを知るのが怖い。SenderWithContext。チャネル経由のメッセージにエラーコンテキストを自動付与する。マルチスレッドのデバッグでは、この情報がないと地獄を見る。地獄は見たことがある。何度もある。
xtaskパターン。MakefileやシェルスクリプトをRustで書くことで、クロスプラットフォーム対応とIDE補完が得られる。これは導入のハードルが低い。低いからこそ、最初の一歩にいい。
正直に言うと、これらのパターンを自分のプロジェクトに導入できるかどうかは分からない。ThreadSendersは6スレッド前提で設計されているし、FatalErrorは「ログを吐いて続行」が正しい場面を見極める目が必要だ。「パターンを知っている」と「パターンを使いこなせる」の間には、溝がある。
Zellijのソースコードは約10万行。すべてを読む必要はないが、以下のファイルは特に参考になる。
xtask/src/main.rs- ビルドタスクの設計zellij-server/src/thread_bus.rs- スレッド間通信のパターンzellij-server/src/plugins/pinned_executor.rs- 動的スレッドプールの設計zellij-utils/src/errors.rs- エラーハンドリングと#[track_caller]zellij-utils/src/channels.rs- エラーコンテキスト付きチャネルzellij-utils/src/consts.rs-OnceLockと定数の設計zellij-tile/src/lib.rs- WASMプラグインのマクロ展開
後編では、PTY処理やANSIパーサーなど、さらに低レベルな実装を見ていく。ターミナルマルチプレクサの核心部分だ。
ただ、一つだけ変わったことがある。unwrap()を見たとき、以前より少しだけ手が止まるようになった。「これは本当にpanicしていいのか」と。その迷いが生まれただけでも、10万行を読んだ意味はあったのかもしれない。
分からないまま、次のコードを書く。