🦔

ブラウザ上で`go run ./main.go`するために(経過報告)

2023/12/25に公開2

この記事はWebAssembly Advent Calendar 2023 25日目の記事です.


GoWasmへのビルドをサポートしているため,GOOSGOARCHを次のように指定することでWasmへビルドすることができます.

GOOS=js GOARCH=wasm go build -o main.wasm main.go

Goをインストールするとき,パッケージマネージャやインストーラ以外に「ソースコードからのビルド」という選択肢があります.
Goの面白いところとして,「GoはGoでビルドできる」というのがあります.

Goのリポジトリをクローンし,src直下の make.bashを実行することで,bin直下にgoコマンドが作られます.

git clone https://go.googlesource.com/go goroot
cd goroot/src
./make.bash

GoをGoでビルドできるなら,Wasmにもビルドできるのでは?

「GoをビルドするにはGoが必要で,GoはWasmへのビルドをサポートしている」ということは,Wasm版のGoコマンドをビルドできるのではないか? と思いませんか?

実際にGoのビルド方法を紹介しているページ「Installing Go from source」にもコンパイラがサポートしている命令セットとしてWasmが載っていることがわかります.

The Go compilers support the following instruction sets:
...
wasm
WebAssembly.

go build同様にmake.bashも実行時にGOOSGOARCHを指定することで,特定のターゲット向けにビルドすることができます.

GOOS=js GOARCH=wasm ./make.bash

この時, bin/js_wasm直下にWasmバイナリのgoコマンドが生成されます.
Goが提供しているJSのグルーコードを利用してビルドされたWasmを動かしてみます.

cd ../bin/js_wasm
cp $(go env GOROOT)/misc/wasm/wasm_exec.js .

次のような go.jsを書くことで,go help runの実行結果を得ることができます.

import "./wasm_exec.js";

const go = new Go();

const { instance } = await WebAssembly.instantiateStreaming(
  fetch(new URL("go", import.meta.url)),
  go.importObject,
);

go.argv = ["go", "help", "run"];
go.run(instance);

(今回は実行にdenoを利用しています)

deno run --allow-read ./go.js

go.jsの実行結果

このようにgo help runの実行結果を得ることができました.
なんだか動きそうな雰囲気があります.

go run ./main.goを実行できるのか?

go help runの代わりに, go run ./main.goを実行してみましょう.

-go.argv = ["go", "help", "run"];
+go.argv = ["go", "run", "./main.go"];

go.jsの引数を書き換えて実行すると次のようなエラーが出ます.

go: cannot find GOROOT directory: /your/repository/clone/path/goroot
exit code: 2

GOROOTのディレクトリが見つからないと旨のエラーが出ていることがわかります.
go.jsに次のように書くことでGOROOTのパスを変更することができますが,エラーの内容はディレクトリのパスが変わるだけで変わりはありません.

go.env = {
  GOROOT: "/usr/local/go",
};

go run ./main.goを実行するために (1)

そもそも,ブラウザ上でファイルへのアクセスをどのように行うのでしょうか?
そのヒントを知るために wasm_exec.jsの中身を見てみると,次のように実装されています.

const enosys = () => {
    const err = new Error("not implemented");
    err.code = "ENOSYS";
    return err;
};

globalThis.fs = {
    // ...
    chmod(path, mode, callback) { callback(enosys()); },
    chown(path, uid, gid, callback) { callback(enosys()); },
    close(fd, callback) { callback(enosys()); },
    fchmod(fd, mode, callback) { callback(enosys()); },
    fchown(fd, uid, gid, callback) { callback(enosys()); },
    fstat(fd, callback) { callback(enosys()); },
    fsync(fd, callback) { callback(null); },
    ftruncate(fd, length, callback) { callback(enosys()); },
    lchown(path, uid, gid, callback) { callback(enosys()); },
    link(path, link, callback) { callback(enosys()); },
    lstat(path, callback) { callback(enosys()); },
    mkdir(path, perm, callback) { callback(enosys()); },
    open(path, flags, mode, callback) { callback(enosys()); },
    read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
    readdir(path, callback) { callback(enosys()); },
    readlink(path, callback) { callback(enosys()); },
    rename(from, to, callback) { callback(enosys()); },
    rmdir(path, callback) { callback(enosys()); },
    stat(path, callback) { callback(enosys()); },
    symlink(path, link, callback) { callback(enosys()); },
    truncate(path, length, callback) { callback(enosys()); },
    unlink(path, callback) { callback(enosys()); },
    utimes(path, atime, mtime, callback) { callback(enosys()); },
};

何も実装されていませんね.
実際に実装されているのはwriteくらいです.

ファイルシステムを実装する

これらのファイルシステムの関数を実装してあげることで,go run ./main.goを動かせるかもしれません.

これらの関数がどのように呼ばれているのか知るために,次のデバッグコードを仕込みます[1]

https://github.com/a-skua/go/blob/72d1154afecdc0051a48c8cf442abe25c9f95bab/web/go.js#L635-L642

これで呼ばれている関数とパラメータの内容が分かるようになるので,必要な関数を実装することができるようになります.
go.jsを実行し,実際に呼ばれている関数を実装していくと次のようになります.

https://github.com/a-skua/go/blob/72d1154afecdc0051a48c8cf442abe25c9f95bab/web/go.js#L519-L600

今回必要なfs関数を実装するために,次のようなブラウザ上で扱える仮想ファイルシステムを用意しました.

https://github.com/a-skua/go/blob/wasm/web/fs.js

srcに変更を加える

ファイルシステム関数を良い感じに実装していくと,fs.readが呼ばれるようになるのですが,GOOS=jsにはio.EOFが実装されていないという問題があります.
グルーコードからも分かるように,ファイルシステムにアクセスするという前提がない無いため,仕方のない部分でもありますが,それではファイルの内容を読み出すことができません.
Goのosパッケージには func ReadFile(name string) ([]byte, error)という便利な関数が用意されているので,ファイルの中身を読み出すのは非常に簡単ですが,より古典的な方法でファイルの中身を読み出すには次のように書く必要があります.

  1. ファイルを開く(open)
  2. io.EOFになるまでファイルを読む(read)
  3. ファイルを閉じる(close)
package main

import (
    "bytes"
    "fmt"
    "io"
    "os"
)

func main() {
    file, err := os.Open("example.txt") // 1. ファイルを開く
    if err != nil {
        panic(err)
    }
    defer file.Close() // 3. ファイルを閉じる

    bin := make([]byte, 32)
    var buf bytes.Buffer
    for {
        n, err := file.Read(bin) // 2. `io.EOF`になるまでファイルを読む
        if err == io.EOF {
            break
        }
        if err != nil {
            panic(err)
        }

        buf.Write(bin[:n])
    }
    fmt.Println(buf.String())
}

GOOS=jsで実際にここの実装を行なっているのは src/syscall/fs_js.gosrc/syscall/tables_js.goの2つのファイルです.

fs_js.goの中ではfsCall("read", ...)として,JSのコードを呼び出しています.

func Read(fd int, b []byte) (int, error) {
    f, err := fdToFile(fd)
    if err != nil {
        return 0, err
    }
    if f.seeked {
        n, err := Pread(fd, b, f.pos)
        f.pos += int64(n)
        return n, err
    }

    buf := uint8Array.New(len(b))
    n, err := fsCall("read", fd, buf, 0, len(b), nil)
    if err != nil {
        return 0, err
    }
    js.CopyBytesToGo(b, buf)

    n2 := n.Int()
    f.pos += int64(n2)
    return n2, err
}

このfsCallの中でエラーのマッピングを行なっているのがtables_js.goになるのですが,ここには io.EOFのマッピングが存在しません.

// TODO: Auto-generate some day. (Hard-coded in binaries so not likely to change.)
const (
    // native_client/src/trusted/service_runtime/include/sys/errno.h
    // The errors are mainly copied from Linux.
    EPERM           Errno = 1       /* Operation not permitted */
    ENOENT          Errno = 2       /* No such file or directory */
    ESRCH           Errno = 3       /* No such process */
    EINTR           Errno = 4       /* Interrupted system call */
    EIO             Errno = 5       /* I/O error */
    // ...
)

// ...

// errnoErr returns common boxed Errno values, to prevent
// allocations at runtime.
func errnoErr(e Errno) error {
    switch e {
    case 0:
        return nil
    case EAGAIN:
        return errEAGAIN
    case EINVAL:
        return errEINVAL
    case ENOENT:
        return errENOENT
    }
    return e
}

// ...

var errnoByCode = map[string]Errno{
    "EPERM":           EPERM,
    "ENOENT":          ENOENT,
    "ESRCH":           ESRCH,
    "EINTR":           EINTR,
    "EIO":             EIO,
    // ...
}

そのため,tables_js.go内にio.EOFのマッピング定義を追加し,go.jsから io.EOFを伝播できるように書き換える必要があります.

// TODO: Auto-generate some day. (Hard-coded in binaries so not likely to change.)
const (
    // native_client/src/trusted/service_runtime/include/sys/errno.h
    // The errors are mainly copied from Linux.
    EPERM           Errno = 1       /* Operation not permitted */
    ENOENT          Errno = 2       /* No such file or directory */
    ESRCH           Errno = 3       /* No such process */
    EINTR           Errno = 4       /* Interrupted system call */
    EIO             Errno = 5       /* I/O error */
    // ...
+   EEOF            Errno = 100_000 // 仮置き
)

// ...

// errnoErr returns common boxed Errno values, to prevent
// allocations at runtime.
func errnoErr(e Errno) error {
    switch e {
    case 0:
        return nil
    case EAGAIN:
        return errEAGAIN
    case EINVAL:
        return errEINVAL
    case ENOENT:
        return errENOENT
+   case EEOF:
+       return io.EOF
    }
    return e
}

// ...

var errnoByCode = map[string]Errno{
    "EPERM":           EPERM,
    "ENOENT":          ENOENT,
    "ESRCH":           ESRCH,
    "EINTR":           EINTR,
    "EIO":             EIO,
    // ...
+   "EEOF":            EEOF,
}
func Read(fd int, b []byte) (int, error) {
    f, err := fdToFile(fd)
    if err != nil {
        return 0, err
    }
    if f.seeked {
        n, err := Pread(fd, b, f.pos)
        f.pos += int64(n)
        return n, err
    }

    buf := uint8Array.New(len(b))
    n, err := fsCall("read", fd, buf, 0, len(b), nil)
-   if err != nil {
-       return 0, err
-   }
    js.CopyBytesToGo(b, buf)

    n2 := n.Int()
    f.pos += int64(n2)
    return n2, err
}

この変更を加えて再度ビルドし,go.jsにもEOFのエラー定義を追加すると,go run -hが実行出来るようになります.

https://github.com/a-skua/go/blob/72d1154afecdc0051a48c8cf442abe25c9f95bab/web/go.js#L505-L509

ブラウザ上でgo run -hを実行した結果

go run ./main.goを実行するために (2)

Goをブラウザ上で動かそうと思い至ってから現状できているのがここまでになります.
ファイルシステムを実装していくとある程度動きそうな雰囲気はあるのですが,ファイルシステムを実装する以外にもGoのコードに手を加えないと動かなそうな感じがしています.
デバッグしている感じ,おそらくGoのコンパイラのキャッシュ機構なのですが,そこを良い感じにWasmでも機能するようにしてあげる必要がありそうです.

ひとまず今回はブラウザ上でgo run ./main.goをできる可能性がありそうだというところまで分かりました.
また経過報告を出せればと思います.

脚注
  1. コードを読み進めることで呼ばれている関数を知る方法もありますが,取っ掛かりがなかったのでこのような手段をとっています. ↩︎

Discussion

Hajime HoshiHajime Hoshi
asukaasuka

初めまして!
この情報助かります.
ありがとうございます〜