mini-tmux

Table of Contents

my env

OpenBSDも7.7が出てsysupgradeしたんだけど、そこから取り残されている環境が ある。7.6の世界ね。 qemu+kvmで動作させてる者だ。ええ怠慢ですよ。でも、これってカーネルをgdb できる用に作成したものなんで、アップデートが面倒なんだ。どうせカーネル観察用 にしか使わないので、これでいいかとなった。

ん訳で、これからtmuxの探検を始めるけど、環境は7.6の世界ね。場合によっては カーネル側にも言及するかも知れないのでね。

それから、もう一つの利点として、qemuで動作させてる場合、シリアルラインを容易 に利用できる事だ。但しこの端末だと、vt220と認識されちゃうので(rows 24)、.profileに 仕掛けをしてる。

[ `tty` == "/dev/tty00" ] && stty rows 35

普通はifを使うんだろうけど、ChatGPTご推薦のlisp風な記述をしてみた。肝は test == [ ってのと、論理式を && で結合するって方式ね。久しぶりに思い出させてくれたね。

最初の一歩

tmux -v すると膨大なログを吐いてくれるって言う嬉しい機能が有るんで、有効 利用させて貰おう。これって随所に盛り込んだprint debugの変形だ。作者 さんは、ここ大事な所だから押さえておけ、ってのの発露だと思うんだ。

題材は、前回やった、sim-gdb.shにする。このスクリプトの中で最初に表われるtmuxの 所をtmux -vとすればいいだろう。

sim-gdb.shを起動して、C-t w それから、q q で終了させた。

ad$ wc tmux-*
     246    2111   16923 tmux-client-16840.log
   30413  164844 1890590 tmux-server-90124.log

サーバー側のログが、とんでもない行数だな。めげずに冒頭部分だけをチラ見する。

ad$ head -4 tmux-client-16840.log
1746652959.621441 client started (16840): version openbsd-7.6, socket /tmp/tmux-1000/default, protocol 8
1746652959.621525 on OpenBSD 7.6 DEEPSEEK#1; libevent 1.4.15-stable (kqueue)
1746652959.621660 flags are 0x18000000
1746652959.621676 socket is /tmp/tmux-1000/default
ad$ head -4 tmux-server-90124.log
1746652959.629689 server started (90124): version openbsd-7.6, socket /tmp/tmux-1000/default, protocol 8
1746652959.629724 on OpenBSD 7.6 DEEPSEEK#1; libevent 1.4.15-stable (kqueue)
1746652959.629899 input_key_build: 0x10e003 (PasteStart) is \033[200~
1746652959.629908 input_key_build: 0x10e004 (PasteEnd) is \033[201~

クライアント側がキックしてサーバーが動き出すとな。プロトコルバージョンって事は 通信の規約が定義されてるんだな。それからlibevent大事だよとな。

クライアント側のログは、こんな風になってた。

1746652959.622651 add peer 0x3f7b8b8a000: 7 (0x0)
1746652959.625157 sending message 111 to peer 0x3f7b8b8a000 (8 bytes)
1746652959.625252 sending message 111 to peer 0x3f7b8b8a000 (8 bytes)
1746652959.625285 sending message 101 to peer 0x3f7b8b8a000 (5 bytes)
 :
1746652959.628304 sending message 106 to peer 0x3f7b8b8a000 (0 bytes)
1746652959.631174 cmd_pack_argv: argv[0]=new-session
1746652959.631183 cmd_pack_argv: argv[1]=-d
1746652959.631189 cmd_pack_argv: argv[2]=-s
1746652959.631195 cmd_pack_argv: argv[3]=simgdb
1746652959.631201 cmd_pack_argv: argv[4]=-n
1746652959.631207 cmd_pack_argv: argv[5]=ksh
1746652959.631215 sending message 200 to peer 0x3f7b8b8a000 (36 bytes)
1746652959.631223 client loop enter
1746652959.631287 client_signal: Child exited
1746652959.789017 peer 0x3f7b8b8a000 message 203
1746652959.789070 client loop exit

スクリプト中に定義されてたtmuxへの引数が散見されるな。

ソースを改変

上のログを見ると、最初の項は、事象が発生した時刻を正確にログしてる事が分る。 高速トレードなんてやっていないんで、そんな正確に時刻表示されたって煩いだけだ。 秒以下の部分は目ざわりなんで、取り払ってしまえ。client.c あたりを眺めてみると

static int
client_get_lock(char *lockfile)
{
        int lockfd;

        log_debug("lock file is %s", lockfile);

こんなのが出てくるんで、これを手がかりに追跡して、

ad$ diff -u /usr/src/usr.bin/tmux/log.c log.c
--- /usr/src/usr.bin/tmux/log.c Sun Sep  1 13:41:52 2024
+++ log.c       Thu May  8 05:25:13 2025
@@ -116,8 +116,8 @@
        free(s);

        gettimeofday(&tv, NULL);
-       if (fprintf(log_file, "%lld.%06d %s%s\n", (long long)tv.tv_sec,
-           (int)tv.tv_usec, prefix, out) != -1)
+       if (fprintf(log_file, "%lld %s%s\n", (long long)tv.tv_sec,
+            prefix, out) != -1)
                fflush(log_file);
        free(out);
 }

こういう改変をした。ええ、元ファイルは温存しといて、ツリーを/tmp/myxにcpして、 そこでの作業ですよ。

ad$ grep log_debug *.c | sed 's/:.*//' | uniq -c | sort -nr | head
  72 server-client.c
  47 input.c
  31 tty.c
  29 screen-write.c
  26 cmd-find.c
  23 window.c
  21 tty-keys.c
  21 resize.c
  17 tty-term.c
  17 format.c
ad$ grep log_debug *.c | sed 's/:.*//' | uniq -c | sort -nr | wc
      51     102     815
ad$ grep log_debug *.c | wc
     541    3820   34483

どのファイルに何箇所ぐらいdebug文が埋め込まれているか。ファイルの個数、それに 監視ポイント数も調べてみた。

ad$ grep log_debug *.c | sed 's/:.*//' | uniq -c | grep cmd-
   2 cmd-display-menu.c
   1 cmd-display-panes.c
  26 cmd-find.c
  13 cmd-parse.c
   3 cmd-pipe-pane.c
  12 cmd-queue.c
   2 cmd-refresh-client.c
   2 cmd-source-file.c
   6 cmd-wait-for.c

コマンドを定義したファイルで重要っぽいのいのには、こんなのが列挙されてた。

check command

折角1秒単位のログにしたんで、ゆっくり実験。/tmp/myx/tmux -v で特製tmuxを起動 。暫くして、tmux display "THIS IS /tmp/myx/tmux" を実行。しばし休んだ後。exitで tmuxを終了。時刻毎に、ログの行数を確認。

ad$ cut -d' ' -f1 tmux-client-44099.log | uniq -c
 240 1746743131
   4 1746743919
ad$ cut -d' ' -f1 tmux-server-98638.log  | uniq -c
19460 1746743131
  25 1746743132
   7 1746743136
 281 1746743146
 411 1746743161
 281 1746743176
 281 1746743191
  :
 281 1746743897
 281 1746743912
  78 1746743913
  78 1746743914
 134 1746743915
  32 1746743916
 270 1746743919

クライアントのログを時刻に翻訳すると、07:25:31に起動して07:38:39に終了 してる事がわかる。それに対してサーバー側では、15秒間隔で動作してる。 これは、ステータス・ラインに表示されてる時計の更新だな。たかが時刻の 更新でも、随分ログが出てるんだな。

ad$ cut -d' ' -f1 tmux-server-98638.log  | uniq -c | sort -nr | head
19460 1746743131
 720 1746743475
 613 1746743476
 569 1746743401
 422 1746743462
 411 1746743882
  :

07:31:15頃にステータスラインに特製メッセージを表示してるんだな。そのあたりを 調べると、ちゃんと痕跡が残っていたぞ。

1746743475 cmd_unpack_argv: argv[0]=display
1746743475 cmd_unpack_argv: argv[1]=THIS IS /tmp/myx/tmux
1746743475 cmd_parse_build_commands 0:0: display
1746743475 cmd_parse_build_commands 0:1: THIS IS /tmp/myx/tmux
1746743475 args_parse_flags: next THIS IS /tmp/myx/tmux
1746743475 args_parse: flags end at 1 of 2
1746743475 args_parse: 1 = THIS IS /tmp/myx/tmux (type STRING)
1746743475 cmd_parse_build_commands: display-message "THIS IS /tmp/myx/tmux"
1746743475 cmdq_get_command: [display-message/0x2c0de4c6600] group 2775
1746743475 cmdq_append </dev/ttyp0>: [display-message/0x2c0de4c6600]
1746743475 cmdq_append </dev/ttyp0>: [server_client_command_done/0x2c0de4b9b00]

from ChatGPT

なかなか手強いので、聞いてみた。

tmuxの内部構造を理解するための追跡方法の提案

  1. コマンド処理の流れを把握するポイント

tmuxのクライアントからの入力(tmux display "HELLO WORLD"など)は、以下のような流れで処理されます:

  1. クライアント→サーバへコマンド送信

– クライアントは、UNIXドメインソケット(/tmp/tmux-UID/defaultなど)経由でサーバにコマンド列(argv)を送信します。

– ソース: client.cの client_main()client_send_command() あたり。

  1. サーバがargvを受信し、コマンドに変換

cmd_unpack_argv() でargvが構造体struct cmd_parse_result に変換される。

cmd_list_parse() がキモ。ここでcmdq用のcmd構造体が作られます。

  1. cmd queueに追加される

cmdq_append() などでコマンドが登録される。

cmdq_continue() でコマンドが1つずつ実行される。

  1. 各cmdのexec関数が呼ばれる

– 例: cmd_display_message_exec() がdisplayの実装。

ここまでの処理がログで1300行にわたって出力されているわけです。多いですが、概ね上記の分岐で整理できます。

  1. 効果的なログ追跡方法

tmux -vvvv -Ltest -f/dev/null などとすると、余計な設定ファイルも読み込まれず、ログもシンプルになります。

以下のログレベルが特に役立ちます:

  • cmd*: コマンドの分解・登録・実行系(最も重要)
  • client*, server*: クライアント・サーバ間の処理
  • tty*, screen*: 実際の表示系(ウィンドウ描画)

ログがシンプルになるって触れ込みだったけど、より複雑 になったぞ。典型的なハルシネーションだな。

ad$ wc tmux-*.log
     244    2127   15179 tmux-client-67835.log
     104     135    1743 tmux-out-93729.log
   24603  138502 1333299 tmux-server-93729.log

tty/pty

tmuxの要はtty/ptyの技術を使って、仮想端末を増やしている。下記の様に、確かに 増量してるな。

ad$ cc ob-samle.c -lutil
ad$ tty
/dev/ttyp0
ad$ ./a.out
ad$ tty
tty
/dev/ttyp1
^C
ad$

基本のコード

// tty/pty sample
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <util.h>
#include <termios.h>
#include <string.h>
#include <sys/select.h>

int main() {
    int master_fd;
    pid_t pid;
    struct termios term;
    tcgetattr(STDIN_FILENO, &term);

    pid = forkpty(&master_fd, NULL, &term, NULL);
    if (pid == 0) {
        execlp("ksh", "ksh", NULL); // using ksh
        perror("execlp");
        exit(1);
    }

    fd_set fds;
    char buf[1024];
    while (1) {
        FD_ZERO(&fds);
        FD_SET(STDIN_FILENO, &fds);
        FD_SET(master_fd, &fds);
        int maxfd = (STDIN_FILENO > master_fd) ? STDIN_FILENO : master_fd;
        if (select(maxfd + 1, &fds, NULL, NULL, NULL) > 0) {
            if (FD_ISSET(STDIN_FILENO, &fds)) {
                ssize_t len = read(STDIN_FILENO, buf, sizeof(buf));
                if (len > 0) write(master_fd, buf, len);
            }
            if (FD_ISSET(master_fd, &fds)) {
                ssize_t len = read(master_fd, buf, sizeof(buf));
                if (len > 0) write(STDOUT_FILENO, buf, len);
            }
        }
    }
    return 0;
}

例によってgdb祭

(gdb) bt
#0  forkpty (amaster=<optimized out>, name=<optimized out>,
    termp=<optimized out>, winp=<optimized out>)
    at /usr/src/lib/libutil/pty.c:102
#1  0x0000035430f51bb5 in main () at ob-samle.c:18

そして、すかさずman

The forkpty() function combines openpty(), fork(), and login_tty() to
create a new process operating in a pseudo-tty.  The file descriptor of
the master side of the pseudo-tty is returned in amaster, and the
filename of the slave in name if it is non-null.  The termp and winp
parameters, if non-null, will determine the terminal attributes and
window size of the slave side of the pseudo-tty.

今度はtmuxにガサ入れします。

ad$ grep forkpty *.c
job.c:          pid = fdforkpty(ptm_fd, &master, tty, NULL, &ws);
spawn.c:        new_wp->pid = fdforkpty(ptm_fd, &new_wp->fd, new_wp->tty, NULL, &ws);

親戚でした。

The fdopenpty() and fdforkpty() functions work like openpty() and
forkpty() but expect a /dev/ptm file descriptor ptmfd obtained from the
getptmfd() function.

mini-tmux

今度は本命のmini-tmux原理の実装。C-b 1,2,3 で3画面を切り替え。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <termios.h>
#include <util.h>
#include <string.h>
#include <sys/select.h>
#include <signal.h>

#define NUM_PTY 3

int master_fd[NUM_PTY];
pid_t pids[NUM_PTY];
int current = 0;

void spawn_shell(int index) {
    struct termios term;
    tcgetattr(STDIN_FILENO, &term);

    pids[index] = forkpty(&master_fd[index], NULL, &term, NULL);
    if (pids[index] == 0) {
        execlp("ksh", "ksh", NULL);
        perror("execlp");
        exit(1);
    }
}

int main() {
    for (int i = 0; i < NUM_PTY; i++) {
        spawn_shell(i);
    }

    fd_set fds;
    char buf[256];

    // canonical(raw) mode
    struct termios orig, raw;
    tcgetattr(STDIN_FILENO, &orig);
    raw = orig;
    cfmakeraw(&raw);
    tcsetattr(STDIN_FILENO, TCSANOW, &raw);

    int switch_mode = 0;

    while (1) {
        FD_ZERO(&fds);
        FD_SET(STDIN_FILENO, &fds);
        FD_SET(master_fd[current], &fds);
        int maxfd = (STDIN_FILENO > master_fd[current]) ? STDIN_FILENO : master_fd[current];

        if (select(maxfd + 1, &fds, NULL, NULL, NULL) > 0) {
            if (FD_ISSET(STDIN_FILENO, &fds)) {
                ssize_t len = read(STDIN_FILENO, buf, sizeof(buf));
                if (len > 0) {
                    if (switch_mode) {
                        if (buf[0] >= '1' && buf[0] < '1' + NUM_PTY) {
                            current = buf[0] - '1';
                            write(STDOUT_FILENO, "\033[2J\033[H", 7);
                            dprintf(STDOUT_FILENO, "[switched to pty %d]\n", current + 1);
                        }
                        switch_mode = 0;
                        continue;
                    }

                    if (buf[0] == '\x02') {  // Ctrl-b
                        switch_mode = 1;
                        continue;
                    }
                    write(master_fd[current], buf, len);
                }
            }

            if (FD_ISSET(master_fd[current], &fds)) {
                ssize_t len = read(master_fd[current], buf, sizeof(buf));
                if (len > 0) write(STDOUT_FILENO, buf, len);
            }
        }
    }
    tcsetattr(STDIN_FILENO, TCSANOW, &orig);  // normal(cooked) mode
    return 0;
}

tmuxやviのようなアプリケーションはrawモード+状態遷移でコマンド入力を処理 する必要が有る。その為、状態フラグ switch_mode を導入して処理をしている。 本物のtmuxは、さぞや大変な事をやってるのだろな。

pstreeで関連部分を確認

|   \-+= 11058 sakae ./a.out
|     |--= 22495 sakae ksh
|     |--= 83413 sakae ksh
|     \--= 06001 sakae ksh

こんな風に起動したものだ。確かにそれぞれの端末でkshが動作してる。

ad$ ./a.out
ad$ echo $$
22495
ad$ tty
/dev/ttyp2
[switched to pty 2] ;; C-b 2
ad$
ad$ echo $$
83413
ad$ tty
/dev/ttyp3

README

オール電化・雨月物語 なんて本を読んだ。帯には、本当に怖いのは、人間の業と 電化製品。雨月物語近未来の新感覚ホラーとあった。

オール電化でホラーと言われたら、真っ先に思い出すのは、地震とか水害でタワマンに 通電出来ない事態。49階までどうやって登るねん。水の出ないトイレはどうすんねん。

まだまだ有るぞ。先月だったな、高速のETCがおかしくなって、高速道路から出るのに 4989してたのも思い出される。強欲な道路会社は、ユーザーの迷惑より自分達の利益 を優先させた。電子決済が停止しちゃって、買い物もできなくなった等等。 オイラーは何時もニコニコ現金払いさ。

それにしてもオイラーは 雨月物語 を、知らなかったぞ。


This year's Index

Home