Open3

Yuniframe: スクリプティングによるテストの準備

okuokuokuoku

... さぁ面倒くさくて辛いやつだ。。

テスト本体はスクリプトで記述し、ホスト側のテストドライバと通信することでテストを進行させたい。 ...ということは、そのテストドライバもスクリプトで書きたいよな。。

Androidにはアプリの起動引数が無かったと思ったけど、Intentを使う方式が一般的なようだ。

https://github.com/baldurk/renderdoc/issues/987

SDL2はhookを用意しているので、そこで getIntent してパラメタを引き出せばOKかな。

https://github.com/libsdl-org/SDL/blob/f522c5380cb371a83d21d3d01f088ce5b9531263/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java#L288-L296

okuokuokuoku

とりあえずいつものlibuvを追加

https://github.com/okuoku/em2native-tests/commit/3abaa2214c873c64d462d0795553981be4f4faaf

ネイティブやiOS simulatorでは 127.0.0.1 (= そのまま接続できる)、 Android emulatorでは 10.0.2.2 が対向ホストのループバックアドレスになっているので、そこでTCP listenしてIPアドレスとポート番号を渡して起動するスタイルで良いんじゃないだろうか。

スクリプトインタプリタへのバインディングはこれから考えることにする。一旦完全イベントドリブンにしてコールバック無しのデザインで行こうかな。。

okuokuokuoku

ネットワークAPIの設計

今回難しいのは、SDL2のイベントキューとlibuvのコールバックをくっつけないといけない点だろう。macOSの都合で、メインスレッドはSDL2のイベントキューで処理することが必須である(macOSではGUIイベントはメインスレッドに配送される)。このため、libuvの処理をサブスレッドに移す必要がある。

... 今回はテスト用と割り切ってしまって適当にデザインすることにする。。ちゃんとしたI/O APIを用意するならOS固有の最適化とか考えたいし、そのためにはスクリプトでC callbackを実装できるようにしないといけないし。。

大きなデザインは以下のようにする:

  1. メインスレッドでSDL2とスクリプトインタプリタを実行する
  2. サブスレットでlibuvを実行する。 libuvのコールバックは原則的にブロックしない 。(ただしSDL2側のイベントキューがいっぱいになった場合やヒープが不足した場合を除く)
  3. libuvからのイベントは別にバッファしておき、独立したイベントキューとして取り出せるようにする。

2 はなかなか難しいポイントだけど、libuvはバッファの寿命をユーザーに制御させる方向にしているので、この方法で実装しても余計なコピーが発生することはない。

3 は、テストドライバがSDL2を使わない(& libuvイベントキューをメインスレッドにする)CUIアプリになるため、スクリプトを共用することを想定すると分けざるを得ない。

このデザインで難しいのはバッファの寿命管理と言える。コールバックを抜けたらバッファも利用完了 -- みたいな仮定を置けない。

スクリプト向けAPI

スクリプト向けには、 TCPのlisten/connect と プロセスの起動をAPIとして用意し、更にbufferの管理用キューを提供する。

オブジェクト

オブジェクト管理はリファレンスカウントで行う。 _create_spawn のAPIで作成されたハンドルは 1 の参照カウントを持ち、解放するためには _destroy する必要がある。 _destroy は失敗しない、が、メモリを解放するとは限らない。

今回の場合はイベントをキューに蓄積するため、 Stream complete の処理が完了してから _destroy しなければならない。そうしなければ、イベントキューから取り出したイベントに含まれるhandleが既に無効という事態になるし、 最悪handleの値が再利用されてしまう 可能性もある。

イベントキュー

void* miniio_current_ctx(void);
int miniio_get_events(uint64_t* buf, int bufcount);

イベントは64bitsの配列に格納して送り出す。パースできないイベントに出会ったら死ぬしかない。(ただし、一応イベントID(32bits)の後にイベント長も入れて送出する。)

イベントヘッダの内容は、

  • 32bit: EventID
  • 32bit: Length
  • 64bit: TimeStamp

TimeStamp の内容は未定。いちいちsyscallして現在時刻を取るのは重そうだし。。

イベントは、

  • 1: Incomming buffer (handle, userdata, bufhandle, bufuserdata, offset, len)
  • 2: Buffer Complete (handle, userdata, bufhandle, bufuserdata, offset, len)
  • 2: Stream Activate (handle, userdata, error, rawerror, readhandle, writehandle)
  • 3: Stream Complete (handle, userdata, error, rawerror)
  • 4: Wakeup (userdata, error, rawerror)

の、一旦5種。DNS lookupとか付けたらさらに増えることになるのではないか。(Socks5にできないのでたぶん入れないけど)

エラーはerrorとrawerrorに分かれる。rawerrorはデバッグ用。errorは0 = 成功、 1 = 中断した。

sleep、時刻

int miniio_timeout(void* ctx, uint32_t ms);

適当に寝る。途中でホスト日付が変更されたりした場合の挙動は未定義。寝るのが完了するとWakeupが成功する。途中で起こされた場合はエラー入りのWakeupが返却される。

TCP

void* miniio_net_param_create(void* ctx, void* userdata);
void miniio_net_param_destroy(void* ctx, void* param);
int miniio_net_param_hostname(void* ctx, void* param, const char* hostname);
int miniio_net_param_port(void* ctx, void* param, int port);
void* miniio_tcp_listen(void* ctx, void* param);
void* miniio_tcp_connect(void* ctx, void* param);

実際の通信はプロセス(のpipe)と同様のストリーム I/O APIで行う。

プロセス起動

void* miniio_process_param_create(void* ctx, const char* execpath, void* userdata);
void miniio_process_param_destroy(void* ctx, void* param);
int miniio_process_param_workdir(void* ctx, void* param, const char* dir);
int miniio_process_param_args(void* ctx, void* argv, int argc);
int miniio_process_param_stdin(void* ctx, void* pipe);
int miniio_process_param_stdout(void* ctx, void* pipe);
int miniio_process_param_stderr(void* ctx, void* pipe);
void* miniio_process_spawn(void* ctx, void* param);
int miniio_process_abort(void* ctx, void* handle);
void* miniio_pipe_new(void* ctx, void* userdata);
void miniio_pipe_destroy(void* ctx, void* pipe);

... 結構API多いな。。 workdir は指定しない場合親のものを引き継ぐ。環境変数の制御はテストでは不要なので省略。pipeは使い捨てとなる(使用後destroyする必要はある)。paramは再利用可能。

abort は失敗する可能性がある; 既にプロセスが終了していてStream completeイベントが送出されている場合。

ストリームI/O

void miniio_close(void* ctx, void* stream);
void* miniio_buffer_create(void* ctx, size_t size, void* userdata);
void miniio_buffer_destroy(void* ctx, void* handle);
void* miniio_buffer_lock(void* ctx, void* handle);
void miniio_buffer_unlock(void* ctx, void* handle);
int miniio_write(void* ctx, void* stream, void* buffer, size_t offset, size_t len);
int  miniio_read(void* ctx, void* stream);

bufferの lock / unlock セマンティクスが必要かどうかはかなり悩みどころ。。とりあえず入れてある。 lock はリファレンスカウントを増加させる。このため、 lock 中のバッファを destroy しても問題ない。

readwrite でイベントのセマンティクスが異なる。 write は対応する完了イベント(Buffer complete)を高々1つ送出する。 read複数の バッファ到着イベント(Incomming buffer)を送出する。Incomming bufferイベント内のbufferは都度 lockしてアクセスし、都度 unlock → destroy する必要がある。

read は暗黙には開始されない(呼び出しまで、buffer completeイベントが来ないことが保証される)。しかし、 read を中断する方法は提供されない。