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


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

はじめに

ターミナルで cat huge_log_file.log を実行した。画面が滝のように流れ始めた。Ctrl+Cを連打した。反応しない。画面はまだ流れている。椅子の背もたれに体を預けて、流れが止まるのを待った。Ciscoルーターの話もしたいが、それを始めるとどこまでも終わらなくなるので、ここでは話をしない。

こういう場面に出くわすことがある。自分が打ったコマンドなのに、自分では止められない。出力が止まったあと、何事もなかったかのように次のコマンドを打つ。たぶん、みんなそうしている。自分もそうしてきた。そうしてきたのだが、ある日ふと気になった。この暴走を、ソフトウェアはどうやって止めているのだろう。

Zellijは、ターミナルマルチプレクサだ。1つのターミナル画面を複数に分割し、複数のシェルを同時に操作できる。tmuxやscreenの現代版として、2021年にRustで開発が始まった。

github.com

本記事は2部構成の前編にあたる。前編では設計パターンを抽出し、後編ではさらに深く実装の内部に入る。読んで「なるほど」と思って、そのまま忘れる。たぶんそうなる。それでいい。合わなければ途中で離脱してもらって構わない。

約10万行のコードベースから、設計が優れている箇所——と、正直「これでいいのか?」と思う箇所——を抜き出して紹介する。他人のコードを読んで「分かった」と言い切れる自信はない。分からないまま書いている部分もある。それでも、書くことにした。

このブログが良ければ読者になったりnwiizoXGithubをフォローしてくれると嬉しいです。

なぜZellijのコードを読むのか

ターミナルマルチプレクサのコードを読む機会はそう多くない。しかし、Zellijには以下の理由で読む価値がある。

  1. 実践的な並行処理: 複数のスレッドが協調して動く仕組みが、教科書的ではなく「本当に動くコード」として見られる
  2. WASMプラグインシステム: ブラウザ以外でWebAssemblyを使う実例として参考になる
  3. エラー処理の設計: 「このエラーは無視していい」「このエラーは致命的」を型で表現するパターンが秀逸

コードを読み始める前に、Zellijが前提としている概念をいくつか整理しておく。知っている人は読み飛ばしてもらっていい。

前提知識

Zellijのコードを読み進める前に、いくつかの概念を押さえておくと理解が早い。正直に言えば、自分もこれらを「完全に理解している」とは言い難い。使ったことはある。使ったことはあるが、説明しろと言われると手が止まる。そういう概念を、改めて整理しておく。

擬似端末(PTY)

ターミナルマルチプレクサの根幹技術だ。PTY(Pseudo Terminal)は、物理的なターミナル装置をソフトウェアでエミュレートする仕組み。マスター側とスレーブ側のペアで構成され、マスター側がZellijのようなプログラム、スレーブ側がシェル(bashzsh)になる。シェルは自分が本物のターミナルに接続されていると思い込んでいるが、実際にはZellijが間に入ってデータを仲介している。

MPSCチャネル

Rustの標準ライブラリにあるstd::sync::mpscは「Multiple Producer, Single Consumer」の略だ。複数の送信者から1つの受信者にメッセージを送れる。Zellijでは各スレッドがこのチャネルでメッセージをやり取りしている。crossbeam-channelというクレートを使うとMPMC(Multiple Producer, Multiple Consumer)も実現できるが、Zellijは基本的にMPSCで設計されている。

Actorモデル

各スレッドを独立した「アクター」として扱い、共有メモリではなくメッセージパッシングで通信するパターン。ZellijのScreenThreadPtyThreadはそれぞれがアクターとして振る舞い、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パターン」の実装だ。

github.com

// 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つある。

  1. クロスプラットフォーム: シェルスクリプトはOS依存だが、Rustはどこでも動く
  2. 型安全: xflagsクレートでCLI引数をパースし、typoコンパイル時に検出
  3. 言語統一: ビルドスクリプトも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>>,
}

receiversVecになっている。なぜ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で導入された。

github.com

Screenスレッドには2つのチャネルがある。通常の無制限チャネルと、上限50個の境界付きチャネル。

これは「バックプレッシャー」を実現するための設計だ。冒頭のcat huge_log_file.logを思い出してほしい。PTYからの出力が速すぎると、画面描画が追いつかない。上限付きチャネルを使うと、バッファが満杯になったときに送信側がブロックされる。

一方、ユーザー操作(ペインの移動、タブの切り替え)は無制限チャネル経由で送られ、即座に処理される。ユーザーがキーを押したのに反応しない、という事態は避けたいからだ。

前提知識で触れたMPSCチャネルとActorモデルが、ここで活きている。各スレッドは自分専用のチャネルからメッセージを受け取り、状態を外部と共有しない。共有しなければ、奪い合いは起きない。この設計により、Arc<Mutex<T>>のような共有ロックを使わずにスレッド間通信を実現している。デッドロックの心配がない。

SenderWithContext:エラー追跡付きチャネル

zellij-utils/src/channels.rsには、crossbeamチャネルのラッパーがある。

github.com

// 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バリアントしかなかった。PtyRenderHorizontalSplitVerticalSplitWriteCharacterなど基本的なものだけだ。5年間で148バリアント以上に成長している。25倍。機能追加のたびにバリアントが増えていった結果だ。

利点はある。

  1. 型安全性: 処理し忘れたバリアントがあれば、コンパイルエラーで検出できる
  2. ドキュメント性: このenumを見れば、Screenスレッドが受け付ける全メッセージが一目で分かる

文字列でメッセージを送る設計(例:"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ではない。

github.com

両者の違いを整理する。

項目 Wasmtime wasmi
実行方式 JITコンパイル インタプリタ
速度 高速 低速
攻撃面 広い(JITは複雑) 狭い
依存 LLVM ピュアRust

実は、Zellijは当初Wasmtimeを使っていた。2025年10月のPR #4449「Migrate from wasmtime to wasmi」でwasmiに移行している。この移行と同時にPinnedExecutor(動的スレッドプール)が導入された。JITコンパイルをやめることで、プラグインごとにスレッドをピン留めする設計が可能になった。インタプリタ方式は遅いが、リソース管理の予測可能性とセキュリティで優れる。

github.com

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();
                // ...
            });
        }
    };
}
  1. thread_local!: WASMは基本的に状態を持たない設計だが、これで状態を保持できる
  2. #[no_mangle]: 関数名をそのまま維持し、Zellijホストから呼び出せるようにする
  3. Protocol Buffers: WASM境界を越えるデータはシリアライズする必要がある

github.com

プラグインの権限管理も見ておこう。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種類

AndroidiOSの権限モデルと同様に、細粒度の制御ができる。

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.dev

セッションの保存時に、レイアウト情報(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 で画面が暴走したとき、裏側ではここのコードが動いていた。あの滝が、ここで生まれている。

github.com

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_ttypre_exec。低レベルなUnixプログラミングだ。

openptyは「master」と「slave」という2つのファイルディスクリプタを作る。Zellijはmaster側を持ち、シェル(bashzsh)は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回のシステムコールで読み取れる量と、メモリ消費のバランスから選ばれたと思われる。

github.com

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チームが保守)を使っている。

github.com

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の選択肢一覧を作るときに使える。

github.com

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::orOption::or_elseを使った設定マージだ。other(後から来た設定)に値があればそれを使い、なければself(既存の設定)を使う。

oror_elseの使い分けにも注目。

  • or: Copyトレイトを実装している型(boolInputMode等)
  • or_else: Cloneが必要な型(PathBufString等)

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();

conststatic OnceLockの使い分けに注目してほしい。

  • const: コンパイル時に決まる。DEFAULT_SCROLL_BUFFER_SIZEはフォールバック値
  • OnceLock: 実行時に一度だけ設定される。設定ファイルやCLI引数から値を受け取れる

OnceLocklazy_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.rschannels::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万行を読んで「すごい」と思ったが、「じゃあ自分は何をするのか」という問いの前では言葉に詰まる。それでも、絞り出してみる。

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

  2. FatalError/non_fatalパターンunwrap()を見たら「なぜここでパニックしていいのか」を問う。その問いに答える設計がFatalErrorだ。自分のコードに入れたら、半分以上のunwrap()が正当化できない気がする。それを知るのが怖い。

  3. SenderWithContext。チャネル経由のメッセージにエラーコンテキストを自動付与する。マルチスレッドのデバッグでは、この情報がないと地獄を見る。地獄は見たことがある。何度もある。

  4. 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パーサーなど、さらに低レベルな実装を見ていく。ターミナルマルチプレクサの核心部分だ。

syu-m-5151.hatenablog.com

ただ、一つだけ変わったことがある。unwrap()を見たとき、以前より少しだけ手が止まるようになった。「これは本当にpanicしていいのか」と。その迷いが生まれただけでも、10万行を読んだ意味はあったのかもしれない。

分からないまま、次のコードを書く。




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

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