WebAssemblyでyesコマンドを作ってみる

# TL; DR

- watを手書きして、WebAssemblyでyesコマンドを実装してみたよ
    - [WAPMからダウンロード](https://wapm.io/search?q=yes) [^1]

```bash
wasmer yes.wasm
y
y
y
...
```

# はじめに

「入門WebAssembly」では、watファイル (WebAssembly Text) 手書きでプログラミングをする方法が紹介されています。

https://www.amazon.co.jp/%E5%85%A5%E9%96%80WebAssembly-Rick-Battagline/dp/4798173592

読んだら自分でも何か作りたくなったので、そこそこ簡単そうな `yes` コマンドに挑戦してみました。

# なぜ yesコマンド？

- helloworldの次くらいに簡単そう ~~(やってみたら意外と難しかった)~~
- ブラウザ以外でもwasmを使ってみたい ([WASI](https://wasi.dev/))
- CLIとして [WAPM](https://wapm.io/) に公開したい

WAPMへの公開の仕方については後日別の記事にする予定です。

(4/7更新) 記事にしました。

https://qiita.com/Syuparn/items/00e9cb4a66bc9398bc88

# 作ったもの

https://github.com/Syuparn/wasm-yes/blob/main/yes.wat

使い方はGNUの `yes` コマンドと同じです。

```bash
# 無限にyを出力
$ wasmer yes.wasm
y
y
y
...

# 引数で自分の好きな言葉を指定可能
$ wasmer yes.wasm no!
no!
no!
no!
no!
no!
```

watファイルを手書きしたことで、ファイルサイズもかなり小さくなりました！

|wasmファイル|サイズ|
|-|-|
|今回作ったもの|394B|
|(参考)TinyGoをコンパイル|464kB|

# ツールの準備

## wasm生成:  wat2wasm 

CIではインストールが楽な node製のものを使用しています。

```bash
$ npm install wat2wasm
$ npx wat2wasm yes.wat
```

https://www.npmjs.com/package/wat2wasm

## wasm実行: wasmer

ブラウザ以外でwasmを実行できるようにランタイムを用意します。今回はWasmerを使用しました [^2]。

https://wasmer.io/

# 実装
## yを無限に出力する

まずは `yes` のメイン機能、 `y` の無限出力を実装します。

## WASI

「文字の出力」はWebAssemblyでは簡単ではありません...
OSのシステムコールを呼ぶためにWASIが必要です。

### 使用する関数をimport

標準出力には `fd_write` 関数を使用します。

https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#-fd_writefd-fd-iovs-ciovec_array---resultsize-errno

仕様書では引数/戻り値の型がRustのような形式で書かれているので、WebAssemblyの形式に読み替える必要があります。
まだ規則を完全に把握できていませんが、

- 構造体は線形メモリ上のポインタ (`i32`) で表現
- 配列は線形メモリ上のポインタと要素数を２つの引数 (`i32`, `i32`) で表現
- Resultはwasm上表現不能なので、最後以外の型パラメータは引数で表現

という変換がなされているようです。
（間違いがあればコメントいただけるとありがたいです :pray:）

```
型定義
fd_write(fd: fd, iovs: ciovec_array) -> Result<size, errno>

fd: ポインタをi32で表現
ciovec_array: ポインタをi32, 長さをi32で表現
size: 戻り値で返せないので引数に含める。ポインタをi32で表現

wasm上の関数型
;;              (*fd, *iovs, iovs_len, *size) (errno)
(func $fd_write (param i32 i32 i32 i32) (result i32))
```

参考

https://stackoverflow.com/questions/69486132/abi-format-of-wasi-functions

上記の型で `fd_write` をimportします。

```wat:yes.wat
(module
  (import "wasi_unstable" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))
  ;; ...
)
```

### 線形メモリを確保

次に、`fd_array` の引数 `fd`, `iovs`, `size` の実体をメモリ上に確保できるようにします。

仕様書の `Size` を見ると線形メモリの必要領域がわかるので、被らないように割り当てます。offsetごとの用途についても `Record members` に書かれているので、指定された内容を保存します。

fd: https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#-fd-handle
ciovec: https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#-ciovec-record

以下の記事も参考にさせていただき、見よう見まねで作ってみました。

https://qiita.com/massie_g/items/f63e711c695cec3d5950

https://github.com/bytecodealliance/wasmtime/blob/main/docs/WASI-tutorial.md#web-assembly-text-example

```wat:yes.wat
(module
  (import "wasi_unstable" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))

  ;; メモリの初期化、エクスポート
  (memory 1)
  (export "memory" (memory 0))

  ;; 出力したい文字列を メモリ[16:18] に保存
  (data (i32.const 16) "y\n")

  ;; CLIツールとしてwasmを実行する場合、 `_start` がメイン関数として起動
  (func $main (export "_start")
    (local $errno i32)

    ;; io vector を メモリ[0:8] に保存 (ciovec 8byte * 1要素)
    (i32.store (i32.const 0) (i32.const 16)) ;; offset 0: 出力文字列("y\n")のアドレス
    (i32.store (i32.const 4) (i32.const 2)) ;; offset 4: 出力文字列("y\n")の長さ

    ;; 無限ループでstdoutに出力
    (loop $next
      ;; "y\n" を出力 (戻り値のエラーはdropで握りつぶす)
      (drop (call $fd_write
        (i32.const 1) ;; stdout
        (i32.const 0) ;; io vectorのアドレス
        (i32.const 1) ;; io vectorの長さ
        (i32.const 8) ;; 書き込まれたバイト数(u32)を格納するためのアドレス (4byteなので メモリ[8:12] に保存)
      ))
    )
  )
)
```

## パイプラインでの絞り込みができるようにする

上記コードで `y` を無限出力できるようになりましたが、`head` で絞り込もうとするとパイプラインが固まってしまいます。

```bash:本物のyesの場合
$ yes | head -n 3
y
y
y
$ 
```

```bash:上記コードの場合
$ wasmer yes.wasm | head -n 3
y
y
y # ここで固まる
```

`head` コマンドは指定した行数を表示したらプロセスを終わらせるために `SIGPIPE` を投げますが、自作yesがこれを握りつぶしているのが原因でした。

参考：「[「シェル芸」に効く！ AWK処方箋](https://books.google.co.jp/books?id=OWNqDwAAQBAJ&pg=PA19&lpg=PA19&dq=head+%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89+%E7%B5%82%E3%82%8F%E3%82%89%E3%81%AA%E3%81%84&source=bl&ots=WSRT9-SPGL&sig=ACfU3U3yT_2oy6XMctLFfbTubD3jOk0Qrg&hl=ja&sa=X&ved=2ahUKEwj145rTjff2AhVIZN4KHTvODiEQ6AF6BAg0EAM#v=onepage&q=head%20%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89%20%E7%B5%82%E3%82%8F%E3%82%89%E3%81%AA%E3%81%84&f=false)」

`fd_write` の戻り値が `0` (正常終了)以外の場合はループを抜けるように修正します。

```wat:yes.wat
    (loop $next
      (local.set $errno (call $fd_write ;; 戻り値のエラーをdropせずに格納
        (i32.const 1)
        (i32.const 0)
        (i32.const 1)
        (i32.const 8)
      ))

      ;; 0でなければエラーが発生しているのでループを抜ける
      (br_if $next (i32.eqz (local.get $errno)))
    )
```

これで `head` が終了するようになりました。

```bash
$ wasmer yes.wasm | head -n 3
y
y
y
$ 
```

## コマンドライン引数を読み込む

続いて、yesのもう一つの機能、「引数が指定された場合 `y` の代わりにその文字列を表示する」を実装します。かなり複雑になったので、まずは完成したmain関数で概要を紹介します。

```wat:yes.wat
(func $main (export "_start")
  (local $errno i32)
  (local $output_ptr i32) ;; 出力文字列のアドレス ("y\n" 、コマンドライン引数どちらも参照できるよう変数化)
  (local $output_len i32) ;; 出力文字列の長さ

  ;; デフォルトでは "y\n" を参照
  (local.set $output_ptr (i32.const 16))
  (local.set $output_len (i32.const 2))

  ;; コマンドライン引数が指定された場合、参照先をこちらへ変える
  (if (call $has_command_line_args)
    (then
      ;; コマンドライン引数情報をメモリに格納し、先頭引数文字列のアドレスを取得
      (local.set $output_ptr
        (call $get_first_command_arg
          (i32.load (i32.const 20)) ;; 引数の個数 ($has_command_line_args が保存したもの)
          (i32.const 28) ;; 引数情報を格納するアドレス
        )
      )
      ;; 改行するために、引数文字列に "\n" を追加
      (call $append_breakline (local.get $output_ptr))
      ;; 引数文字列の長さを取得
      (local.set $output_len (call $len_str (local.get $output_ptr)))
    )
    (else nop) ;; 指定されない場合は何もしない　(else省略不可のため nop指定)
  )

  ;; io vector初期化
  (call $initialize_iov (local.get $output_ptr) (local.get $output_len))

  ;; 無限ループで文字列出力
  (loop $next
    (local.set $errno
      (call $fd_write
        (i32.const 1) ;; stdout
        (i32.const 0) ;; io vectorのアドレス
        (i32.const 1) ;; io vectorの要素数
        (i32.const 8) ;; 出力文字列バイト数を格納するアドレス
      )
    )

    ;; エラーが起きたらループを抜ける
    (br_if $next (i32.eqz (local.get $errno)))
  )
)
```

### コマンドライン引数の個数を取得: args_sizes_get

まずは引数が指定されたかどうかを調べる関数を作ります。
`args_sizes_get() -> Result<(size, size), errno>` はコマンドライン引数の個数と文字列データの長さを返します。

https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#-args_sizes_get---resultsize-size-errno

前述の規則に従うと、引数は sizeのポインタ2つ、戻り値はerrnoで `(func $args_sizes_get (param i32 i32) (result i32))` のシグネチャになります。

```wat:yes.wat
(func $has_command_line_args
  (result i32)
  (drop (call $args_sizes_get
    (i32.const 20) ;; メモリ[20:24] にコマンドライン引数の個数を保存
    (i32.const 24) ;; メモリ[24:28] に引数文字列データ長を保存
  ))
  ;; 引数の個数が1より大きければ、引数が指定されている
  ;; NOTE: 0番目引数はコマンド名自身（ `yes.wasm` ）なので、2つ以上指定される必要がある
  (i32.gt_u (i32.load (i32.const 20)) (i32.const 1)) ;; return len(args) > 1
)
```

### コマンドライン引数の中身を取得: args_get

続いて、1番目引数が指定された場合はその中身を取得します。
`args_get(argv: Pointer<Pointer<u8>>, argv_buf: Pointer<u8>) -> Result<(), errno>` はコマンドライン引数のアドレスの配列(`argv`)と引数データ(`argv_buf`)を格納します。

https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#-args_getargv-pointerpointeru8-argv_buf-pointeru8---result-errno

`argv_buf` は全コマンドライン引数を `\0` で結合した一続きのデータで、`argv` の各要素は `argv_buf` 中の該当引数開始場所のアドレスを持っています。

```:イメージ
コマンドライン引数: "a bc def"

0         4         8        12          
| argv[0] | argv[1] | argv[2] |   argv_buf   |
|    12   |   14    |   16    | "a\0bc\0def" |
```

`argv` は配列のため (4 * 引数の個数)バイトの可変長の領域を使用します。そこで、先ほど取得した引数の個数を使用し `argv` と `argv_buf` の領域が被らないようにしています。

```wat:yes.wat
;; 1番目コマンドライン引数文字列のアドレスを取得
(func $get_first_command_arg
  ;;         引数の個数             引数データ格納開始アドレス
  (param $n_args i32) (param $args_data_ptr i32) (result i32)

  ;; コマンドライン引数のデータを読み込み保存
  (drop (call $args_get
    ;; argv: コマンドライン引数のアドレスの配列
    ;; argv[n] は memory[$args_data_ptr+4*n:$args_data_ptr+4*(n+1)] へ保存される
    (local.get $args_data_ptr)
    ;; argv_buf: コマンドライン引数のデータを格納するアドレス
    ;; argvと衝突しないよう memory[$args_data_ptr+4*$n_args] から開始
    (i32.add (local.get $args_data_ptr) (i32.mul (local.get $n_args) (i32.const 4)))
  ))

  ;; argv[1] の中身が1番目引数のアドレス
  ;; NOTE: argv[0] はコマンド名 "yes.wasm" なので無視
  (i32.load (i32.add (local.get $args_data_ptr) (i32.const 4)))
)
```

### 引数に "\n" を追加

出力のたびに改行する必要があるので、格納した引数文字列に `\n` を追加します。
やっていることはヌル文字 `\0` を見つけたら `\n` に置き換えているだけです [^3]。

```wat:yes.wat
(func $append_breakline
  (param $str_ptr i32) ;; 文字列のアドレス

  (local $i i32)
  (local.set $i (local.get $str_ptr))
  (loop $next
    (if (i32.eqz (i32.load8_u (local.get $i))) ;; if memory[i] == '\0'
      (then
        ;; memory[i] を '\n' に置き換え 
        (i32.store8 (local.get $i) (i32.const 10))
        ;; memory[i+1] を '\0' に置き換え
        (i32.store8 (i32.add (local.get $i) (i32.const 1)) (i32.const 0))
        (return)
      )
      (else nop)
    )
    (local.set $i (i32.add (local.get $i) (i32.const 1))) ;; i++
    (br $next)
  )
)
```

### 文字列の長さを取得する

最後に、io vector 生成のために引数文字列の長さを数えます。こちらも単に `\0` が見つかるまでインクリメントしているだけです。

```wat:yes.wat
(func $len_str
  (param $str_ptr i32) (result i32)
  (local $n i32)
  (local.set $n (i32.const 0))

  (loop $next
    (if (i32.eqz (i32.load8_u (i32.add (local.get $str_ptr) (local.get $n)))) ;; if memory[str_ptr+n] == '\0'
      (then
        (return (local.get $n))
      )
      (else nop)
    )
    (local.set $n (i32.add (local.get $n) (i32.const 1))) ;; i++
    (br $next)
  )
  (unreachable) ;; 戻り値未指定でコンパイルエラーにならないよう、この行に到達しえないことを明記
)
```

これでようやく、引数が出力できるようになりました。

```bash
$ wasmer yes.wasm foo | head -n 3
foo
foo
foo
```

# テスト作成

ここまでwatファイルで実装してきましたが、「素人が手書きしたwasmなんか怖くて動かせるか！」とお思いの方もいらっしゃるでしょう。~~ごもっとも~~

そこで、今回はBatsを使ってCLIのテストを作成しました。

https://github.com/bats-core/bats-core

BatsはBashの拡張で、コマンドの出力結果が想定通りかテストできるフレームワークです。

まずは `yes` が `y` の文字を出していることをテストします。

```bash:test/yes.bats
@test "yes without argument writes 'y'" {
  result="$(wasmer yes.wasm | head -n 1)" # 無限ループするとテストが終わらないので先頭行のみ取得
  [ "$result" = "y" ]
}
```

テストが通りました。

```bash
$ npx bats -x test/yes.bats
 ✓ yes without argument writes 'y'

1 tests, 0 failures
```

結果が等しくない場合は、以下のようなエラーが出ます。エラー箇所がわかりやすいように `-x` コマンドはいつもつけておくことをおススメします。

```bash
$ npx bats -x test/yes.bats
 ✗ yes without argument writes 'y'
   (in test file test/yes.bats, line 5)
     `[ "$result" = "n" ]' failed
   $ [yes.bats:4]
   $ result="$(wasmer yes.wasm | head -n 1)"
   $ wasmer yes.wasm
   $ head -n 1
   $ [ "$result" = "n" ]

1 test, 1 failure
```

yを出し続けることも、以下のようにテスト可能です。

```bash:test/yes.bats
@test "yes always writes 'y'" {
  lineno=0
  for result in $(wasmer yes.wasm | head -n 10); do
    echo "line $((lineno++)):"
    [ "$result" = "y" ] # n行目の出力が "y"
  done

  [ "$lineno" -eq 10 ] # 10行出力された
}
```

[思いつく限りテストケースを書いた](https://github.com/Syuparn/wasm-yes/blob/main/test/yes.bats)ので、最低限動作していると信じたいです...

# おわりに

以上、watファイル手書きで `yes` コマンドを実装した紹介でした。~~思った以上に記事が長くなってしまいました :innocent:~~

今までwasmの中身はブラックボックスと思っていましたが、実際に書いてみるとかなり取っつきやすいと感じました。作ったものはWAPMで公開しているので、この方法についても後日記事にしたいと思います。

ここまでお付き合いいただきありがとうございました！

[^1]: なぜかモジュールのページに直接飛ぶとInternal Server Errorが出るので検索ページのURLを貼っています。
[^2]: ちなみにWAPMをインストールするとWasmerも自動インストールされます。
[^3]: 文字列を他の用途に使わないので破壊的変更を加えていますが、ライブラリ化するならどこかへ新規文字列をコピーしたほうがお行儀がいいですね...