Unengineered Weblog

PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND

Unixのファイルシステムを9Pでサーブするu9fsを「ちょっと」使う

github.com

u9fsはPlan 9に付属している(のに)Unix向けのソフトウェアである。これはUnixファイルシステムPlan 9通信プロトコル9Pでサーブし、Plan 9でマウントすることを目的としている。私は初め使い方が全く分からなかったので、私と同じ人のためにメモを残す。

若者のinetd離れ

u9fsのソースコードにはソケットを操作する部分が見当たらなかったため、初めて読んだとき(こいつはどうやって通信するんだ)と悩んだ。 答えをいうとこのソフトウェアはinetdを使って通信するのだ。 私は"若い" Unixユーザーなので最近あまり使われていないinetdを知らなかったのである。 inetdはsuffixのdが示すとおり、デーモンである。 使い方はWikipediaのページを参照してほしい。

ja.wikipedia.org

要するにinetdは接続がacceptされた際に登録されたプログラムを起動し、そのプログラムの標準出入力をソケットにフックするのだ。 つまりinetdを使って通信するu9fsは標準出入力を通して9Pをサーブしているのである。 これがわかればinetdを使わなくても通信できる。

u9fsの使い方を示した日本語の記事を紹介しよう。

p9.nyx.link

oraccha.hatenadiary.org

inetdを使わずにちょっと使ってみる

この記事の本題である。ちゃんと使うにはinetdを設定する必要があるが、面倒である。 ちょっと使うだけなら次の方法できるだろう。

netcat

一番簡単なのはnetcat (nc)を使う方法である。

fifo=/tmp/tmpfifo.$$
mkfifo $fifo
./u9fs -a none -l /dev/null -n -u $(id -un) < $fifo | nc -l localhost 5640 > $fifo

あまり用いられないnamed pipeの格好の利用例でもある(ちなみにmktemp(1)に似た一時的なnamed pipeを作るコマンドmktempfifoがある)。 設定は接続時の認証は無し(-a none)、ログファイルは残さず(-l /dev/null)、u9fsを通して作ったファイルのオーナーは自分(-u $(id -un))としている。 オプションについて詳しくはmanページを参照してほしい。

そして別のターミナルから接続してみよう(9pはplan9portにあるコマンドで、9Pのクライアントである)。

$ 9p -a 'tcp!localhost!5640' ls -l
d-rwxr-xr-x M 0 root    root        106496 Jul 23 12:45 bin
d-rwxr-xr-x M 0 root    root          4096 Jul 23 12:36 boot
d-rwxrwxr-x M 0 root    root          4096 Mar  2  2020 cdrom
d-rwxr-xr-x M 0 root    root          5120 Jul 23 15:13 dev
d-rwxr-xr-x M 0 root    root         12288 Jul 23 12:45 etc
...

この方法は一度しか接続できないという問題点がある。

listen

github.com

これは私が作成した簡易版inetdである。とても単純なプログラムだ(Plan 9のユーザープログラムにこれに似たlisten1がある)。

$ listen -a 'localhost:5640' ./u9fs -a none -l /dev/null -n -u $(id -un)

と起動する。これは接続ごとにu9fsをexecするので同時に何度も接続することが可能である。

君はPythonでcatが実装できるか?

これは技術ブログでない。ただの日記だ。

皆様はPythonUnixのコマンドcatを実装できますでしょうか?それっぽいのではなく、ちゃんと標準の/bin/catと同じ動作するものを作ろうとすると意外と引っかかる部分が多いと思います。

まず最も単純な実装(cat1.py)を見てみましょう。

#!/usr/bin/env python3
import sys

BUFSIZE=1024

def cat(f):
    while True:
        b = f.read(BUFSIZE)
        if len(b) == 0:
            return
        print(b, end='')

def main():
    if len(sys.argv) == 1:
        cat(sys.stdin)
    else:
        for fname in sys.argv[1:]:
            with open(fname) as f:
                cat(f)

main()

これは一応catっぽいことをします。引数にテキストファイルがあればそれらを、なければ標準入力をを標準出力に書き出します(標準的なcatは引数に-を指定すると標準入力から読み取るという仕様がありますが、今回それは無視します。そもそも現代のUnixでは/dev/fd/1を指定すればいいし)。 BUFSIZEの大きさは適当に決めてしまいました。『詳細UNIXプログラミング』という書籍が参考になるかも。

これには問題点があって、Python面倒なことにバイナリファイルとテキストファイルを区別します。 だからバイナリファイルを入力すると例外を起こしてしまいます。

$ ./cat1.py /bin/sh
Traceback (most recent call last):
  File "./cat1.py", line 21, in <module>
    main()
  File "./cat1.py", line 19, in main
    cat(f)
  File "./cat1.py", line 8, in cat
    b = f.read(BUFSIZE)
  File "/usr/lib/python3.10/codecs.py", line 322, in decode
    (result, consumed) = self._buffer_decode(data, self.errors, final)
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf0 in position 24: invalid continuation byte

ファイルをバイナリモードでオープンしないといけませんね。次のプログラム(cat2.py)に書き換えます。

#!/usr/bin/env python3
import sys

BUFSIZE=1024

def cat(f):
    while True:
        b = f.read(BUFSIZE)
        if len(b) == 0:
            return
        sys.stdout.buffer.write(b)

def main():
    if len(sys.argv) == 1:
        cat(sys.stdin.buffer)
    else:
        for fname in sys.argv[1:]:
            with open(fname, "rb") as f:
                cat(f)

main()

バイナリはsys.std{in,out}.bufferを通して標準出入力します。また関数open"rb"と引数を追加してバイナリモードでopenすることを指示しなければなりません。

これでバイナリファイルも扱えるようになりました。

$ ./cat2.py /bin/sh | sha256sum
4f291296e89b784cd35479fca606f228126e3641f5bcaee68dee36583d7c9483  -
$ ./cat2.py < /bin/sh | sha256sum
4f291296e89b784cd35479fca606f228126e3641f5bcaee68dee36583d7c9483  -
$ sha256sum /bin/sh
4f291296e89b784cd35479fca606f228126e3641f5bcaee68dee36583d7c9483  /bin/sh

catが実装できた。めでたしめでたし。

と、思いきやこれで終わりではありません。 Pythonはデフォルトで標準出入力やファイルをバッファリングします。このプログラムもバッファリングしているでしょう。しかしながらGNUやらPlan 9やらのcatは出入力をバッファリングしません。Python版でもバッファリングしないように実装したいですね。

バッファリングについて解説するのは省いて、バッファリングによって/bin/catとcat2.pyが異なる動作をする例を確認してみましょう。つぎのGoのプログラム(coproc)を使います。

package main

import (
    "fmt"
    "log"
    "os"
    "os/exec"
)

func main() {
    if len(os.Args) < 2 {
        log.Fatal("no args")
    }

    c := exec.Command(os.Args[1], os.Args[2:]...)
    w, _ := c.StdinPipe()
    r, _ := c.StdoutPipe()
    if err := c.Start(); err != nil {
        log.Fatal(err)
    }

    nbuf := 100
    buf := make([]byte, nbuf)

    greeting := []byte("hello\n")
    w.Write(greeting)
    fmt.Printf("input: %q (%d bytes)\n", greeting, len(greeting))

    n, _ := r.Read(buf)
    fmt.Printf("output: %q (%d bytes)\n", buf[:n], n)

    greeting = []byte("bye\n")
    w.Write(greeting)
    fmt.Printf("input: %q (%d bytes)\n", greeting, len(greeting))

    n, _ = r.Read(buf)
    fmt.Printf("output: %q (%d bytes)\n", buf[:n], n)

    w.Close()
    if err := c.Wait(); err != nil {
        log.Fatal(err)
    }
}

このプログラムは引数に与えられたプログラムをfork/execで起動します。 coprocは起動したプロセスへパイプを通してhello\nをwriteしたら100bytesぶんreadし、またbye\nをwriteしたら100bytesぶんreadするといった単純なものです。 /bin/catと./cat2.pyで試してみましょう。

$ ./coproc /bin/cat
input: "hello\n" (6 bytes)
output: "hello\n" (6 bytes)
input: "bye\n" (4 bytes)
output: "bye\n" (4 bytes)
$ ./coproc ./cat2.py 
input: "hello\n" (6 bytes)

/bin/catでは予想通りの結果が出力されプログラムが終了しました。 しかしながらcat2.pyは出力がされずプログラムが固まってしまいました。 なぜでしょう?

writeシステムコールは重いシステムコールと呼ばれ、実行が遅いです。 ちょっとの文字列しか出力しないprintfのような関数で毎回writeシステムコールを呼んでいたらプログラムは遅くなってしまいます。 だからPythonsys.std{in,out}.bufferやCのstdio.hは出力するデータをバッファに一時的に貯め、まとまった量を一気にwriteします。 これをバッファリングといいます。 ちなみにCのstdio.hではデータを貯める量(バッファーサイズ)をBUFSIZとして定義してあります。 readシステムコールも同様です。

すると上記の./coproc ./cat2.pyが固まってしまった理由がわかるでしょう。 coprocからパイプを通して書き込まれた6bytesのhello\nはcat2.pyのバッファーサイズよりも小さいですから、cat2.pyはwriteせずにバッファに貯めます。 その状態でcoprocがパイプをreadしても読み込むものはありません。 通常この状態になるとreadシステムコールはプログラムをブロックします。 これによってcoprocは固まってしまったのです。

一方通常の/bin/catはバッファリングしていないですから、readしたらすぐにwriteします。 catはデータの加工をしませんから、まとまった量を一気にreadして、そのままwriteしちゃえばいいんです。 この点においてcatは一行ずつreadして加工をして一行ずつwriteする他のプログラム(grepやらsedやら)と異なります。

話をもとに戻しましょう。バッファリングしない完成版Python版cat(cat3.py)は次のようになります。

#!/usr/bin/env python3
import sys

BUFSIZE=1024

def cat(f):
    while True:
        b = f.read(BUFSIZE)
        if len(b) == 0:
            return
        sys.stdout.buffer.raw.write(b)

def main():
    if len(sys.argv) == 1:
        cat(sys.stdin.buffer.raw)
    else:
        for fname in sys.argv[1:]:
            with open(fname, "rb", buffering=0) as f:
                cat(f)

main()

sys.std{in,out}.bufferの代わりにsys.std{in,out}.buffer.rawを通して入出力します。 またopenの引数にbuffering=0が追加しました。 これをcoprocで起動してみましょう。

$ ./coproc ./cat3.py 
input: "hello\n" (6 bytes)
output: "hello\n" (6 bytes)
input: "bye\n" (4 bytes)
output: "bye\n" (4 bytes)

ようやく/bin/catと同じ動作になりました。 Pythonでcatを実装するのも簡単ではないですね。 バッファリングやらread/writeシステムコールの知識も必要だし、これまでsys.std{in,out}.buffer.rawなんか知らなかったし、Pythonも難しいですね。

awkでpstreeを実装

タイトル通り。

github.com

[init]-+-[systemd-journald]
       |-[systemd-udevd]
       |-[systemd-oomd]
       |-[systemd-resolved]
       |-[systemd-timesyncd]
       |-[acpid]
       |-[avahi-daemon]---[avahi-daemon]
       |-[@dbus-daemon]
       |-[irqbalance]
       |-[python3]
       |-[polkitd]
       |-[power-profiles-daemon]
       |-[snapd]
       |-[accounts-daemon]
       |-[cron]
       |-[switcheroo-control]
       |-[systemd-logind]
       |-[udisksd]
       |-[wpa_supplicant]
       |-[ModemManager]
       |-[NetworkManager]-+-[dhclient]
       |                  `-[dhclient]
       |-[python3]
       |-[sshd]
       |-[gdm3]---[gdm-session-worker]---[gdm-x-session]-+-[Xorg]
       |                                                 `-[gnome-session-binary]
       |-[colord]
       |-[nvidia-persistenced]
       |-[rtkit-daemon]
       |-[upowerd]

こういったヴィジュアルな出力をするツールを作るのは楽しい。また連想配列getlineによるコプロセスの起動、関数定義などといったawkの様々な機能を使いこなすので、awk入門にも向いてる。

sedのコマンドとして使える英単語

sedの置換表現s/foo/bar/のセパレータは/でなくても良い。\\n以外の1byteキャラクターなら何でも良い。だからsequenceも使える。

$ echo _qu_ | sed sequence
_nc_

他に使える英単語は?次のシェルスクリプトを使って探そう。

#!/bin/sh

words=/usr/share/dict/words

for c in a b c d e f g h i j k l m n o p q r s t u v w x y z
do
    grep -E "^s${c}[^${c}]+${c}[^${c}]*${c}\$" "$words"
done

Ubuntuで実行した結果は次の通り

sanatoria
sanitaria
sarcomata
savanna
secede
secrete
secretive
segregate
selective
selvedge
sentence
sentience
sentimentalize
septette
sequence
serenade
serene
serpentine
serviceable
serviette
settee
severance
severe
sewerage
sextette
stateliest
statement
stealthiest
stoutest
straightest
straightjacket
straitjacket
strategist
streetlight
stretchiest
strictest
structuralist

ダム端末でzshをまともに使う方法(9term, Acme, Emacsなど)

zshmacOSのデフォルトログインシェルである。多機能で便利なシェルであるが、ダム端末で使われることを想定していないようだ。この記事ではダム端末とはshellやコマンドの出力をそのまま表示する端末のこととしよう。Acme, 9term, Emacs shellなどはダム端末である。それに対してxtermやGNOME Terminal, Terminal.appはVT100をエミュレートしている端末で、出力に含まれるESC [から始まるエスケープシーケンスによって文字色を変えたりする機能をもつ(この辺はよくわかってない。Wikipediaなどの情報を参照している)。xtermを想定した出力をダム端末に送ると、ダム端末はエスケープシーケンスを解釈できず、エスケープシーケンスがそのまま奇妙な文字列として表示されてしまうのである。zshをデフォルト設定で用いると出力されるエスケープシーケンスによってAcmeEmacsの表示が崩れる。

acmeユーザーならシェルにrc(1)を使うだろう(私も同様)から、誰がこの記事を参照するんだろう?

解決策

次のソースを$HOME/.zshrcに書けばOK

if [ "$TERM" = dumb ]
then
    unsetopt ZLE
    unsetopt PROMPT_CR

    export PS1='%0~ %# '
    export PAGER='col -xb'
    export EDITOR='ed'
fi

解説

現在の端末がダム端末かどうか調べるには環境変数TERMを確認すれば良い。

Zsh Line Editor (ZLE)

ZLEはreadlineみたいな機能を提供してると思われる。こいつがむちゃくちゃな出力をする元凶。オフ。

PROMPT_CR

xterm上の端末ではzshは出力の最後に改行がついていない場合、%を出力して自動的に改行する。ダム端末では最後に改行がついてるかどうか判別できず、常に末尾に%をつけてくる。

環境変数

PS1

文字の色がないプロンプトにする必要がある。端末の文字色はエスケープシーケンスによって制御しているからだ。色がつかなければ何でもよい。

PAGER

通常はman(1)やgit(1)はless(1)を起動して出力を表示する。しかしながらダム端末ではlessなど動かない。これらのコマンドは環境変数PAGERが設定されているとlessの代わりに$PAGERを起動して表示する。col(1)はエスケープシーケンスを削除してくれるコマンドである。manの出力にはタイトルや強調に使うエスケープシーケンスが含まれているのでそれらを削除する。colの代わりにcatを用いるとエスケープシーケンスが削除されずにダム端末によっては表示されてしまう。

lessみたいに行を少しずつ表示をしたければBrian W.Kernighan, Rob Pikeの「Unixプログラミング環境」に紹介されているpを使えば良い。plan9portにも提供されている。このソフトウェアはエスケープシーケンスを使っていないので正常に動作する。

EDITOR

ダム端末ではviなどのスクリーンエディタは利用できない。コマンドによってはエディタが不意に起動する場合もあるので、スクリーンエディタが起動できないようにすること。ここではedとした。

参考になる文献

xtermのエスケープシーケンスについて www.xfree86.org

過去のPOSIXにあったcolコマンド(今は削除されたみたい) pubs.opengroup.org

シェルスクリプトでpickを作った(シェルスクリプトのwhile read...の中で対話的なreadをする方法)

Brian W. KernighanとRob Pikeの「Unixプログラミング環境」にはpickと呼ばれるコマンドが紹介されている。 これは対話的にプログラムを処理するためにシェルスクリプトで書かれたツールである。pick args...とすると、引数を一度に1つずつ表示しユーザーの入力を待つ。pickはユーザーがyを入力した引数のみ出力し、それ以外は捨てる。

$ ls
file1 file2 file3 file4
$ rm $(pick *)
file1? y
file2?
file3? q
$ ls
file2 file3 file4 # file1 has been removed!

その書籍の後半では、引数のかわりに標準出力を使うオプション-を追加するためにCで書き直している(今回私が作成したpickでは-iオプションにした)。当時のシェルでは実現しづらかったからである。 しかしながら、現代のシェル(POSIX shellでも)ではCを用いずに完全版pickを実装できる。それは僕が疑問であった「シェルスクリプトのwhile read...の中で対話的なreadをする方法」の回答でもある。(ネットであまりシンプルな回答を見つけることができなかった。)

github.com

では早速pickのメインルーチンを見てみよう。

if [ "$iflag" = true ]
then
    cat -- ${1+"$@"}
else
    for i
    do
        echo "$i"
    done
fi |
while read -r line
do
    printf '%s ?' "$line" > /dev/tty
    read -r a < /dev/tty # On old Unix, it did not work
    case "$a" in
    y*) printf '%s\n' "$line";;
    q*) exit 2;;
    esac
done

コメントのところに注目してほしい。「Unixプログラミング環境」にはread var < filenameはできないと書いてある。しかしながら現代のshellではこのプログラムは正常に作動する(dash, bash, kshで確認した)。ここで、read -r a < /dev/ttyは明示的に/dev/ttyを指定しなければならない。標準入力は一行目のwhile read -r lineが読んでいるからだ。

表題の答えは

whlie read ...の中で対話的なreadをする方法」はread var < /dev/ttyが答えとなる。ちなみに「確実に」対話的につかうプログラムを除いて、基本的に/dev/ttyを使うべきでない(ed(1)ですら使っていない)。出入力を固定すると他のプログラムと組み合わせることが不可能になるからである。ファイル名を指定してデータを入出力する方法も同様である。基本的にはstdin, stdout, stderrを使ってデータを入出力し、パイプを通して他のプログラムと通信できるように設計すべきだ。

Termuxのトリビア

新しいものが見つかるたびにこまめに更新する予定

TermuxAndroid上でLinuxのシェルが動かせるアプリケーションである。現在はF-Droidまたは直接apkをダウンロードすることによりインストールができる。このページではルート化していないAndroidについて述べている。

雑多

  • ルートディレクトに相当するのは$PREFIX
  • /dev/std{in, out, err}は使えない。/dev/fd/{0,1,2}を使うべき
  • Termux上で/bin/shを実行しても、shebang#!/bin/shをもつファイルを実行しても、インタープリターは$PREFIX/bin/dashが使われる。自動的にパスが修正されるようだ。AWKなども同様。

proot-distro

ユーザーモードchrootであるprootをつかってTermuxにLinuxの主要なディストリビューション環境を構築するプログラムである。例えばAlpine LinuxUbuntuの環境が用意されていて、その環境下でapkやaptを使うことができる。この中ではGCCも動く。欠点は遅いこと。

plan9portを動かす

plan9portをそのままコンパイルしようとしてもうまくいかない。ソースを修正したらコンパイルできたが大変な作業だった。proot-distroを使うとソースを修正せずに動かせたので、こっちを使うのが良いだろう。