Rust コンパイルバックエンド仕様
本書は hikari build --aot が使う Rust バックエンドの仕様を定める。CLI としての
hikari build は build.md を参照。
Rust はネイティブ AOT の唯一の生成先である。 hikari build は同梱モードと AOT モードの
2 つを持ち、AOT モードが出力する言語は Rust の 1 本である。ビルドには Rust ツールチェーンが要る —
これは AOT に限った話ではなく同梱モードも同じで(同梱モードが抱える処理系も Rust だからである。
build.md「ホスト要件」)、持たない利用者は .hika をそのまま hikari で走らせる。
本書が「オラクル」と呼ぶのは、受け入れの基準として使った撤去済みの実装である。移行は完了しており、突き合わせる相手はもう存在しない。オラクルを引く記述はどれも当時の受け入れの根拠であって、今回せる手順ではない。処理系の内部用語の一覧は ../internals/vocabulary.md にある。
契約は LIR の op 集合とランタイム層 API の性質で定義され、物理レイアウトはランタイム層の
内側の実装判断である。生成の側がそこに依存しないので、値の持ち方を変えても契約は動かない
(§2.1)。
生成先は今 Rust の 1 本だけである。 別のランタイム層を持つ実装が並ばない限り、独立した
実装同士の出力を突き合わせて契約を検証することはできない — 適合ハーネス(§6)が
突き合わせる Rust 生成と VM は同じランタイム層をリンクするので、両者の一致が検証するのは
エミッターであってランタイム層ではない。
本書が定めるのは第 1 段の範囲である。回収器は第 2 段で足した(§7.1)。中断・スケジューラー・
ホスト能力はなお範囲外で、それぞれ §7 に将来枠として書く。
1. 生成の経路
.hika → lexer → parser → HIR → LIR → Rust ソース → cargo build
| 段階 | 受け持つもの |
|---|---|
| 出力する | compiler/rustgen |
| ランタイム層 | rt/(Rust クレート) |
| 雛形 | Cargo.toml の path 依存 |
| ビルドする | cargo build |
ランタイム層をリポジトリ直下に置く。 生成された一時プロジェクトの Cargo.toml が
rt/ を path 依存で指すためで、処理系のワークスペースの内側に置くと生成物が
ワークスペースごと引くことになる。
コンパイラー自身も Rust へ移った
LIR までを作る側も Rust である。 意味論(lexer / parser / hir / typecheck /
lir)・実行系(lirvm)・エミッター・周辺ツール(format / lint / highlight / lsp)・
CLI がすべて compiler/ の下にある。
LIR に外部表現(直列化)は作らない。 コンパイラーとランタイム層が同じプロセスに居るので、
LIR はメモリ上の構造体のまま渡る。したがって最小の出荷単位はパイプライン全体である — 層を
1 つずつ切り替えて配る形にはならない。移行の間、層ごとの受け入れは撤去済みの実装を
オラクルに立てて、同じ入力に対する出力を突き合わせる差分検証で行った。
言語構文・意味論・診断・CLI exit code は不変である。 利用者から見た振る舞いは変わって
いない。
cgo も FFI も共有 ABI も使わない。 境界はソーステキストとツールチェーンにあり、2 つの
ランタイム層はプロセス内で出会わない。コンパイラーはソースを書いた時点で仕事を終える。
2. 値と呼び出し規約
2.1 値表現
値は「整数タグ + 即値ワード + 箱への参照」の 3 つ組である。Int / Float /
Bool / () はボックス化しない。箱は「レイアウト記述子 + 宣言順のスロット値ベクター」を持ち、
値自身はレイアウトを持たない。
- 名前は生成時に整数へ interning される。 実行時に文字列でスロットを引くことは無い。
- 種別の判別は整数タグの分岐で行う。値は vtable を持たず、trait object も使わない。
- レコードは幅構造型なので静的型からは記述子が一意に決まらない。受け手の記述子が生成時に
分かれば添字は定数になり、分からなければ記述子が持つ昇順表を二分探索する。
箱への参照はアリーナの添字である。 参照をポインターで持ってホストの GC に任せる形とは、
ここで物理レイアウトが分かれる。契約は参照の表現を定めていないので、この差は契約の内側に
収まる。
箱のアリーナは回収器を持つ。 通常モードは sweep で空いた添字を freelist で再利用し、検証
モード(HIKARI_VERIFY_GC)は墓標を残すため添字を再利用しない。回収器は第 2 段で足した
もので、ルートは走っているフレーム鎖と、値を握るモジュールが名乗り出た口から列挙する(§7.1)。
文字列は別のアリーナに載り、そちらも回収器を持つ。 文字列値の即値は文字列アリーナへの
添字であって箱の添字ではないので、箱のマーク表では辿らない — ルートの列挙は共通のまま、値の種別で
別のマーク表へ振り分ける(§7.1)。通常モードの freelist と検証モードの墓標も箱と同じ形である。
ソース位置は静的な表である
位置を持つ命令にはマークを付け、マークから file:line:col への表を生成時に出力する。実行時にマークを書き込む
のは位置を持つ命令だけで、表を引くのは診断を組むときだけである。
マークを付ける命令の集合はすべての実行系で同じでなければならない。 一部だけが位置を持つと、
同じプログラムが実行系によって違う pos を返す。pos は Hikari から読める値なので、
「stderr は突き合わせない」という適合ハーネスの限定はここに及ばない。集合は lir が持つ
1 つの関数が答えるので、Rust 生成もその関数を引く — 独自に判定してはならない。
2.2 呼び出し規約
生成関数は fn(&mut Frame) -> Step の一様形で、局所値はフレームのスロットベクターに載る。
中断をまたいで生きる状態をホストのスタックに置かない。
Step は閉じた集合である。
| Step | 意味 | 第 1 段 |
|---|---|---|
Return |
結果を呼び出し元へ返す | 到達する |
Call |
呼び先を呼ぶ。復帰後は再開点から続ける | 到達する |
Tail |
同じフレームを再利用して呼び先へ移る | 到達する |
Suspend |
現フローを中断する | 到達しない(第 3 段で到達点ができた・§7.2) |
Unwind |
非局所脱出(制御値 / panic) | 到達しない(その後 ? と制御値で到達) |
Resume |
切り離した継続を繋ぎ直す | 持っていなかった(第 3 段で足した・§7.2) |
到達しないものも先に定義する。 後から足すと、Step を受けるトランポリンと生成側の分岐が
両方変わる。集合を先に閉じておいたので、中断(第 3 段)で増えたのは到達点だけで、生成側は
1 行も変わらなかった。
Resume は 5 つで閉じたつもりが写し損ねていたものである。 効果ハンドラーの継続は
「鎖を繋ぎ直して内側から走らせ直す」ので、他の 5 つのどれとも別の遷移になる — Call は
鎖を 1 段伸ばし、Tail は積み替え、Return と Unwind は戻る向きである。オラクルは
最初から 6 つを持っていた。
生成関数は関数ポインターで持つ(type Code = fn(&mut Frame) -> Step;)。trait object に
しないのは、値に vtable を持たせない方針と同じ理由である。
引数が足りない呼び出しは部分適用値になる。自己参照(inner-name)は呼び出し規約が埋める専用
スロットで受ける — 閉包を作る側では渡せない。
未束縛スロットが 1 つなら、渡された値はまとめて 1 つのタプルになる
(language-spec.md §3.5)。f(a, b) がタプル (a, b) の適用と同義になるのは
この位置だけで、受け手が 1 スロットしか空けていなければ destructure せず丸ごと束縛する。
これは言語の意味論であって実装の都合ではない — 誤ると println (1, 2) のような呼び出しが
片方の実行系だけで落ちる。スロットが 2 つ以上あって実引数がそれを超えるときはエラーである。
2.3 自己末尾再帰は反復である
末尾位置にある self への完全適用は、呼び出しスタックを増やさず反復として走る。 これは
性能の話ではなく言語の保証であり、生成物もこれを守らなければならない。
末尾位置の判定は LIR 上で行い、VM と同じ判定を使う。判定が割れると、同じプログラムが
一方の実行系だけでスタックを溢れさせる。低レベル化の仕方は 3 つで、どれも深さが増えない。
- 平坦化しない関数では、本体を
loopで包み引数の入れ替えとcontinueにする。
引数はまとめて代入する — 実引数が引数スロット自身を読む形(recur(b, a))があるので、
1 つずつ書くと後の引数が書き換え済みの値を読む - 平坦化する関数では、引数を入れ替えて入口ブロックへ戻る。フレームを積まないので
同じく深さが増えない - 中断しうる関数の自己末尾再帰は
Step::Tailになる。トランポリンがフレームを積み替える
ので、フレーム鎖も伸びない
第 1 段は中断を持たないので、現れるのは前の 2 つである。Step::Tail の経路は定義したうえで
到達しない(§2.2)。
束縛名の自己参照にはマークを付けない。 定数スタックが保証されるのは inner 宣言であり、
保証されていない範囲で実行系を割ってはならない。
2.4 状態機械への平坦化
呼び出しは再開点を要求するので、呼び出しを含む関数は基本ブロックへ平坦化され
loop { match fr.resume { … } } の形で出る。呼び出しを含まない関数は構造化のまま出し、
Rust の分岐とループにそのまま乗る。
自己末尾再帰を除くすべての呼び出しが再開点を持つ。 中断しないと証明された呼び出しまで
再開点にするのは、意図した代価である。
そこをホストの呼び出しで済ませる道もある。ホストのスタックが際限なく伸び、ホストの GC が
それを走査してルートを見つける処理系ならそれでよい。自前の回収器を持つ実行系は同じ近道を
取れない:
- フレーム鎖が入れ子のトランポリン呼び出しのローカルに散り、外側の鎖はホストのスタック越しに
しか辿れない。ルートが列挙できない(§7.1 の回収器はこの列挙に乗る) - ホストのスタック深さが非末尾再帰の上限になる。実測では深さ 20 万の非末尾再帰が VM で通り、
こちらだけスタックを溢れさせて名前を出さずに abort していた(第 1 段の
「止まるときは名前を出す」も破れていた)
代価は呼び出しの経路が長くなることで、呼び出しだけのマイクロベンチ(fib 27)で約 28% 遅い。
契約を真にするための代価として受け入れる — 性能はゲートが見張る(§6)。
分割の条件と「平坦化するか」の判定は同じ方針から引く。割れると、呼び出しを含むのに
再開点を持たない関数が出る。方針は共有の平坦化器が持ち、生成側は選ぶだけである。
平坦化しない関数は局所値をホストのローカル変数へ載せる。 契約は「中断をまたいで生きる
状態をホストのスタックに置かない」であって「局所値を常にフレームへ置く」ではない。
ここを行使する関数には回収の安全点を置けない(§7.1)。第 2 段は後退辺を安全点にするが、
妥当性の条件は「局所値がすべてスロットにあること」だからである。載せ替えるなら、その関数は
平坦化へ倒すか安全点を落とすかのどちらかになる — 検査がこれを守る。
誰も読まない書き先には _ を当てる。宣言してよいのは出てきたコードが実際に綴っている
ローカルだけである(LIR の読み書き回数は当てにならない)。Rust は未使用の束縛を警告するので、
ここを間違えると生成物が警告付きでビルドされる。
2.5 型値は静的な表である
型式は実行時の値を含まないので、型記述子は生成時に確定した静的成果物である。型値は
記述子そのものではなく表への添字を指し、割り当てを伴わない — 型パターンを踏むループが
1 反復に 1 個の箱を捨てることを避ける。
子は同じ表の添字で指す。 型は自分自身を参照できる(type Html := { children: List(Html) |})
ので、記述子同士を参照で結ぶと初期化に後埋めの段が要る。添字なら循環が自然に表せる。
型値の検分表示は宣言名ではなく中身を展開した形である。診断に出る表記とは別物で、記述子は
両方の綴りを持つ。
2.6 引数不足は部分適用である
実引数が宣言スロット数に足りなければ、呼び出しは部分適用値になる(language-spec.md §3.6)。
束縛済みの実引数は関数値そのものが運び、呼び先は変わらない — 増えるのは束縛の数だけである。
未束縛スロット数(§3.5 の R)は束縛済みを引いた残りである。引き忘れると、部分適用した値へ
の呼び出しでタプル規則の判定が狂う。
タプルの規則は両向きに効く。f(a, b) がタプル (a, b) の適用と同義なら f((a, b)) も
同義でなければならないので、R が 1 の受け手へ渡した複数実引数はタプルへまとめ、R が 2 以上の
受け手へ渡したタプルは展開する。要素数が合わなければ引数数不一致で止める(単一値の curry とは
違い、タプルは完全な引数列である)。
引数 0 個の呼び出しは部分適用ではない。 それは既定値を埋める明示的な kick である
(language-spec.md §3.6)。未束縛スロットへ既定値を補完してから本体へ低レベル化し、埋める値の無い
スロットが残るなら部分適用へは倒さない — 明示 kick は「これで完成させる」と書いた形なので、
完成しないなら引数数不一致で止める。
関数値は既定値を運ぶ。 欄を捨てて関数値だけ作ってはならない — 「止まらないのに答えが
違う」形になり、出力の突き合わせでしか見つからない。既定値はリテラルを評価した地点で確定した
値である。部分適用は持ち越し、検分表示は record と同じスロットの並びになる。
これは body を省略したリテラルにこそ効く(language-spec.md §2.2)。{ a := 1 |} は
既定値を持つ構築子なので、飽和させない綴り(o の検分表示・o.a の読み)も明示 kick(o!)も
そのまま通る。
2.7 refinement の述語は呼び出し規約に乗る
照合はコード中のいたるところに現れる(パラメーター束縛は呼び出しのたびに走る)ので、中断しない
形で出力する。そこへ中断の可能性を持ち込むとすべての注釈検査に再開点が要ることになり、タグ 1 つ
を比べるだけの位置まで状態機械へ乗る。
ところが refinement の述語はユーザーのブロックなので中断しうる。解決は「refinement のときだけ
呼び出し規約に乗せる」で、種別は生成側が静的に知っている(§2.5 の記述子)ので中断しうる
位置だけが再開点を持つ。
基底型の照合は先で、純粋である。refinement { v: Int | v > 0 } の Int は中断せず、基底が
外れた時点で述語は評価しない(§17.7)。中断しうるのは述語の適用だけである。
この入口はランタイム層が本体ごと持つ関数定義である。生成側は関数定義を登録して値として
参照するだけで、本体は書かない — 再開点を持つ以上、素の関数呼び出しでは表せない。
2.8 構造照合は 3 値で答える
v matches T と、種別ごとの速い道を持たない注釈の照合は構造を辿る。判定は
一致・不一致・判定できないの 3 値である。
2 値にしてはならない。 第 1 段が扱わない種別に当たったとき、不一致へ丸めると
VM が真を返す位置で偽を返すことになる — 止まるのは段の切り方だが、違う答えを返すのは
欠陥である(§6)。判定できないと答えれば、その位置は名前を出して止まる。
3 値は OneOf の意味論にも要る。OneOf は集合なので候補の並び順で結論が変わってはならない
(language-spec.md §17.2)。素朴に「順に試して、判定できない候補に当たったら止まる」と書くと、
その候補が前に居るときだけ止まり後ろに居れば当たってしまう。判定できない候補は握って先へ進み、
どれも当たらなかったときにだけ止まる。
判定を生成側へ写さない。 matches と注釈の照合は同じ規則で答えなければ、同じ型が現れる
位置によって違う答えを出す。速い道(タグ 1 つで決まる基底型)だけを生成側が振り分け、構造を
辿る判断はランタイム層に 1 つだけ置く。
第 1 段が判定できないのは、値の側の情報がまだ無い種別である — 関数型(値が宣言している引数型・
結果型を関数定義へ載せていない)・関数値のスロット・refinement(述語を走らせる必要がある)・
Future と不透明型(対応する値の種別を持たない)。関数値のスロットは「無い」と答えては
ならない位置で、部分適用で束縛済みのスロットは値を持ちうる。
3. ランタイム層 rt の性質
次の性質を機械検査で守る。
| 性質 | 理由 |
|---|---|
| 処理系のクレートを参照しない | ランタイム層は意味論に依存しない。依存すると移植の単位が閉じない |
| 標準ライブラリだけに依存する | 契約の内側は 0 本を保つ。 ホスト能力の実装は外側の別クレートへ出し、そこでだけ外部クレートを許す(§5) |
| trait object を持たない | 値に vtable を持たせない方針(§2.1)と同じ |
| スレッドと非同期ランタイムのクレートを依存に持たない | 中断はフレーム鎖の操作で表す。ホストの並行機構を持ち込むと、フローが値であるという性質が崩れる |
末尾の 1 つは移植で新しく足したものである。 オラクル側の同じ検査は上 3 つに相当するものを
持っていたが、並行機構の依存は見ていなかった。
4. 対応ターゲット
第 1 段はホストターゲットだけをビルドする。
go build は GOOS / GOARCH を渡すだけで 1 台から全ターゲットをビルドできるが、cargo は
ターゲットごとにツールチェーンとリンカーを要求する。同じ体験にはならないので、第 1 段では広げず、
--target <os>/<arch> を --rust と併用したらエラーで停止する。黙って無視したり、
ホスト向けに低レベル化してビルドしたりはしない — 要求したものと違う成果物を返さない。
5. 外部依存は段ごとに数える
依存を足すたびに、それが契約の内側か外側かを記録する。内側に外部依存を入れないことが、
ランタイム層が守ってきた性質である(§3)。
第 1 段は外部クレートを 1 つも足さない。 多倍長整数(Big)は第 1 段のスコープ外なので
(§7)、標準ライブラリだけで足りる。
内側は 0 本を保つ — 生成物がここへ依存するからである
rustgen が出力する Cargo.toml はランタイム層だけを引く。したがって内側へ依存を足すと、
その能力を使わないプログラムの生成物まで依存を背負う。ホスト能力の実装を rt/host
という別クレートへ分けているのは同じ理由で、生成側は使うモジュールに応じて出し分けている。
外側で許すのは 2 つの位置だけである
ホスト能力の実装(内側から見て外側)では、次のどちらかに当たるものに限って外部クレートを
足してよい。
- オラクル自身が外部ライブラリを使っている位置 — SQLite エンジンがこれに当たる。
ここで 0 本に拘っても、写す相手が既に依存を受け入れている以上、守れる性質が無い - ホストの標準の境界が 2 つの言語で違う位置 — termios と TLS は、オラクルが標準ライブラリで
叩けるのに対し Rust のstdには無い。境界を埋めるものに限る — 正規表現の
エンジンのように、オラクルが標準ライブラリで持っていて Rust では書けば足りるものは
自分で書く(std の差ではなく実装の差だからである)
この位置で実際に引いたのは 2 つである。 termios・子への signal 送信・エントロピー
取得のための libc、TLS のための rustls(純 Rust の実装。証明書の解析とルート証明書の
読み出しを含めて 3 クレート)。
OpenSSL を要求する実装は採らない — ビルドする機械に何が入っているかで通るビルドが
変わる(同梱の SQLite と同じ理由である)。
条件同梱は Cargo の feature で対応させる。 オラクルはビルドタグで外し、外したビルドでは
「穴を埋めない」(open は Err(io_error))。この実装も同じ既定 — 足した能力は feature を
立てたときだけ入り、立てないビルドは同じ Err を返す。
処理系の側で許すのも同じ 1 つ目の位置である
ランタイム層の外——コンパイラーと CLI——でも、オラクル自身が外部ライブラリを使っている
位置なら足してよい。上の 1 つ目と同じ規準で、そこで 0 本に拘っても守れる性質が無いのは
同じである。
この位置で実際に引いたのは 1 つである。 対話シェルの行編集(rustyline)。オラクルは
reeflective/readline と golang.org/x/term で組んでいて、担うのは行編集・履歴・複数行
継続・ライブ着色・raw mode の取得と復帰である。
契約の内側には入らない。 生成物が引くのはランタイム層だけなので、ここへ足した依存は
生成物に乗らない — rustgen が出力する Cargo.toml は変わらない。
規準を満たさないものは自分で書く。 正規表現のエンジン・多倍長整数・JSON・SMT-LIB2 を
話す口はどれも「オラクルが標準ライブラリで持っている」ので、この実装も自分で書く(実際
そうしてある)。
6. 受け入れ
適合ハーネスが Rust 生成と VM を突き合わせる。分類は MATCH / MISMATCH /
STAGE1_STOPPED / RUST_ERROR / VM_ERROR / NONDET で、突き合わせるのは stdout ・
stderr ・ exit code の 3 つである。stdout と stderr は別々に比べる — 2 本を 1 本へ
流し込んでから比べると、両方へ書くプログラムで合流の順序が走らせるたびに変わる。
欠陥は MISMATCH と、宣言していない RUST_ERROR だけで、残りは突き合わせる相手が居ないか
(VM_ERROR・NONDET)、段が意味を持たない入口を踏んだ場合(STAGE1_STOPPED。名前を集計する)
である。切り分けの正は tools/aot-conformance/classify.hika が持つ — ここに写した名前は
読み手の見当を付けるためのもので、増減はあちらが決める。
この突き合わせが検証するのはエミッターである。 Rust 生成と VM は同じランタイム層を
引くので、両者の一致はランタイム層の正しさを言わない(§1)。独立した実装を
並べて突き合わせる形は今無いので、ランタイム層そのものはこのハーネスの外で守る — クレート
試験(make verify-gc が回収器のシャドウ検証と併せて回す)と、割り当ての厳密比較
(make aot-alloc-check)である。
第 1 段の受け入れは、算術・分岐・再帰・閉包・record / list / tuple が MATCH することである。
面と意味を分ける
prelude を要求しないコーパスは存在しない。 どんなに短いプログラムでも self-host した
prelude が生成物へ入る — 実測では 1 だけのプログラムが FuncDef 23 個を出力し、ランタイム層の
入口 67 種類(メソッドディスパッチ・variant・制御値・範囲・添字)を要求する。
そこで第 1 段は面と意味を分ける。
| 第 1 段で要るもの | |
|---|---|
| 面(型とシグネチャがあり、コンパイルが通る) | 生成物が触れるランタイム層の入口すべて |
| 意味(正しく動く) | エントリから実際に到達する部分だけ |
prelude の関数は生成されるが、エントリが呼ばなければ実行されない。したがって未到達の入口は
面だけを置く。
未実装の入口は黙って誤答しない。 到達したら「第 1 段では未実装である」と stderr へ書いて
非ゼロで止まる。適合ハーネスはこれを STAGE1_STOPPED として拾うので、MISMATCH に化けて
「実装したつもり」になることがない。停止した入口の名前がそのまま次に実装すべきものである。
今低レベル化できている範囲
実測である(make aot-conformance が同じ観測を回す)。欠陥が 1 件でもあれば失敗させる —
欠陥は MISMATCH ・ RUST_ERROR ・ VM_ERROR の 3 つである。止まるのは段の切り方なので
欠陥ではないが、違う答えを返すこと・生成した Rust がビルドできないこと・VM が転んで
突き合わせそのものが成り立たないことは欠陥である。
判定できないことを欠陥から外さない。 VM が転ぶ本は突き合わせる相手がいないので適合を
判定できないが、そこを見逃すと、その本はコーパスに居るのに何も検証しないまま残り、それでも
全体は緑になる。除外表は持たない — 意図的に転ばせたい本が要るなら、そのとき宣言の仕組みを
設計する。
第 1 段の受け入れは満たされている。 受け入れの構文を狙った 17 本すべてが VM と一致し、
MISMATCH は 0 である — 算術・分岐・再帰・閉包・record / list / tuple、および比較・等価・
浮動小数・文字列・束縛・入れ子リスト・レコードのスロット読み。
std のメンバーは綴りごとに表を持つ
std: が供給する値(math.sqrt の実体)は低レベル化できる。 名前は生成時のキーであって実行時に
引く文字列ではないので、生成コードはランタイム層の値を直に指す。生成側は「綴り → その値を
作る Rust 式」の閉じた表を持ち、この表の大きさがそのまま境目になる。表に無い綴りは
関数ごとスタブになって名前を出して止まるので、黙って誤答することはない。
綴りは VM の表と 1 対 1 である。 片方にしか無い綴りができると、同じプログラムが実行系に
よって走ったり止まったりする。差は std:hikari 系の 5 つだけで、これは生成物が持ちようが
ない — 字句解析器と生成済みリファレンスデータを要求し、生成物が抱えるのはランタイム層
だけだからである。対応は機械で検査する(片方に足しただけの状態を実行時まで黙らせない)。
名前で引く欄は配線を出力しないと黙って誤答する
ランタイム層には受け手の中から綴りで探す欄がある — time.of の暦フィールド・
run_opts の設定・http の要求と応答・演算子スロット・== / compare / -> / length で
ある。interning は生成時に済んでいるので、綴りと NameID の対応は生成側が渡すしかない。
渡し忘れは止まらない。 ランタイム層はその欄を「無い」と読み、既定の意味へ落ちる —
ユーザー定義の == を置いた 2 つの値が「等しくない」と答える形になり、出力の突き合わせで
しか捕まらない。したがって次の 2 つを対にして置く。
- 配線を生成側が出力する(
setupの中)。ランタイム層が組むレコードの記述子も同じ扱いで、
こちらは未配線なら「配線されていない」と言って落ちる - 綴りの一覧がランタイム層のものと一致することを機械で検査する。一覧から漏れた綴りは
配線されず、実行時の診断も出ない
メソッド供給は 3 つの口である
メソッドは 3 つの別々の口から来る。1 つ通しても他の 2 つは通らない。
| 口 | 実装のあり処 | 生成側がすること |
|---|---|---|
組込メソッド(.length()) |
ランタイム層 | 受け手と綴りを渡して実装を選ばせる |
共有ソース由来(.map) |
self-host した prelude の生成関数 | 判別表へ入れ、束縛は部分適用で作る |
受け手が生成時に決まらない位置(Any 越し) |
上の 2 つのどちらか | 名前で引く解決へ渡す(スロットが先・メソッドが後) |
実装を選ぶのはランタイム層である。 綴りが型で割れるもの(length は List と String で
別の実装)は受け手の種別で選ぶので、生成側が綴りだけで決めてはならない。同定そのものは
生成時に済んでいるから、実行時に引くのが数値か綴りかは実装の内側の判断である。
受け手種別名をタグの群へ開くのもランタイム層である。 1 対 1 ではない — 統一モデルの
{ slot-list | body } はランタイム層で 3 つの種別へ割れているので、共有ソースの List メンバーは
割った先のどれにも入れなければならない。生成側はその表を持たず、種別名のまま渡す。
埋められない穴は、埋めるまで面だけにする
生成物が引くのはランタイム層のコアだけなので、外側の実装を要する能力(SQL エンジンほか)は
既定では生成物の中に無い。コアはそこを「持たないビルド」として扱い Err(io_error) を返す —
枝そのものは正しい(条件同梱で外したビルドの振る舞いである・§5)。正しくないのは、穴を埋めて
走る VM とそれを突き合わせることである。同じプログラムが両者で違う答えを返す。
そこで、実装を引き込めるまでの間、生成側はその綴りを踏んだ地点で止まる面へ替える。
第 1 段が決して犯してはならないのは「止まらずに違う答えを返す」ことなので、答えを返せないなら
止まる側へ倒す。引き込めるようになったら本物へ戻す(次節)。
替えた面は本物と同じ形にする。 実引数の数が合わなければ呼びの側で別の答え(部分適用・
引数超過のエラー)になり、止まる地点まで届かない。末尾の省略可能な引数も据える。
食い違いは種別ではなく本文に出ることがある。 端末の生 I/O は、穴を埋めた側が実際の
ioctl のエラーを名乗り、埋めていない側は「このホストに端末が無い」と言う — どちらも
not_a_terminal なので、種別だけを見ていると一致に見える。
テストファイルの入口は普通の入口ではない
_test.hika として低レベル化したプログラムの top-level は、test の値を集めるためだけに走る。
走らせるのはその後で、集計して報告し、1 件でも落ちたら非ゼロで終える。
終了コードを写すのは生成物の判断である。 落ちた試験は協調的な終了を要求するだけで
プロセスは落とさない(exit がトランポリンの外まで巻き戻るのと同じ仕組み)。受けて
プロセスの終了コードにするのは生成した main の側で、標準出力を流し切ってから落とす —
先に落とすと報告の最後の行が消える。
狙ったコーパスと実コーパスは別に測る
受け入れの構文を狙って書いたコーパスで緑であることは、実コーパスで緑であることを
意味しない。 後者は AOT を考えずに書かれた実物だからである。make aot-conformance が
examples/ を回して両方を測る。
見るのは MISMATCH と RUST_ERROR が 0 であることである — 違う答えを返すものも、ビルド
できないものも無いこと。MATCH の件数はコーパスの成長で動くので受け入れの基準にはしない。
恒久的に止まる入口は 1 つである — std のメンバー hikari.* は処理系そのものを要求するので、
生成物が持ちようがない(処理系を内蔵する実行系にしか埋められない)。それ以外の
STAGE1_STOPPED は第 1 段の範囲(§7)の問題であって、範囲が広がれば消える。
組込は生成側で作り直さない
値そのものをランタイム層から受け取る。自前の関数定義にすると「宣言名を持たない組込」のマークが
付かず、効果ハンドラーの横取りから外れる — handle の操作スロットは操作の名前で引くが、その判定は
「呼び先がユーザーの書いた関数ではないこと」を先に見るからである。同じ理由で、診断の呼び出し
境界にも書かれていない名前が出る。
横取りは呼び出し規約の側で行う。 操作の側には手を入れず、トランポリンが呼び先のフレームを
作った直後にハンドラーを 1 度だけ見る。したがって生成側がすることは「ランタイム層の値をそのまま
配る」ことだけで、handle を書いていないコードの経路長も変わらない。
中断は生成側を 1 行も変えない
fork / spawn / all は制御に参加する組込なので、生成側がすることは値を配ることだけ
である。生きた状態がすべてフレームにあるという呼び出し規約の約束(§2.2)が、そのまま中断の
実装になっている — 中断はフレーム鎖を持ち替えることで、生成コードから見れば普通の呼び出しと
区別が付かない(§7.2)。
値の形と呼びの形は 1 対 1 でなければならない。 片方にしか無い名前があると「呼べるのに
参照できない」という食い違いになる。f := range のように組込を値として束ねる形は実在する。
深く走らせると、浅いところでは踏まなかった欠けが出る
メソッド供給を入れたとき、それまでスタブの中に隠れていた欠陥が表に出た。
- 浮動小数リテラルの綴りが 2 つのジェネレーターで割れていた。 この実装が指数へ倒す閾値を
持たず、1e+308を 309 桁で書いていた。生成物としては読めるが、突き合わせはソース全文
なので、そのファイルは丸ごと不一致になる
入れた変更が作ったものではなく、踏めるようになって初めて見えたものである。
壁を 1 つ外すたびに、その奥に隠れていた欄の取りこぼしが出ると考えてよい。
展開済みループは 4 つが揃って 1 つになる
while は LIR の分岐へ展開されるので、関数境界を跨いで巻き戻ってきた Broke /
Continued を受け取るフレームが実行時に無い。そこで呼び出しの側がマークを立て、復帰の直後に
そのマークを読んで振り分ける。
| 部品 | 生成側がすること |
|---|---|
| 捕捉のマーク | 呼び出しの直前に「境界で捕まえずこのフレームへ返せ」を立てる |
| マークの読み取り | 復帰の直後に読んで降ろす。値の形では判定できない(Broke という形の値が正常な戻り値でありうる) |
| 脱出 | 制御値を載せて巻き戻す。ホストの例外機構は使わない |
| 反復 | 最内はラベル無し、外側を指す脱出だけがラベルを引く |
4 つのうち 1 つでも欠けると黙って壊れる。 マークを落とせば break が呼び先の境界で panic に
なり、マークを降ろし忘れれば次の反復が「捕まえた」と誤読する。
ループの末尾に脱出を置いてはならない。 Loop は無限ループで、出口は Break だけである。
置くと本体が 1 度しか走らない — 止まらずに答えが違う形になる。
オラクルが自分自身と割れる入力は非決定として数える
時計・乱数・壁時計に依るプログラムは、VM を 2 度走らせても一致しない。答えが違うことに意味が
無いので、MISMATCH と混ぜてはならない。
除外表は持たない。 答えが違うと見えたときだけオラクルをもう一度走らせ、自分自身と
一致しない入力を非決定として数える。除外表だと「決定的なのに割れている」を黙って隠せて
しまう。
ホスト能力は使うプログラムだけが背負う
生成物の Cargo.toml は既定ではランタイム層だけを引く(§5)。ホストの穴を要求するメンバーを
使ったときだけ rthost を feature 付きで足し、生成した入口が rthost::install() を呼ぶ。
対応はメンバーの綴りごとに持つ。 モジュール単位ではない — 同じ std:term でも、制御列を
組み立てるだけのメンバーは穴を要さないからである。
| std のメンバー | 穴 | feature |
|---|---|---|
db/sqlite.open |
SQL エンジン | sqlite |
term.raw_mode / read_key / peek_key / size |
端末の生 I/O | term |
http.request / http.serve / net.connect_tls |
TLS 終端 | tls |
process.exec / run / run_opts |
子へのシグナル | signal |
crypto/random.bytes |
ホストのエントロピー源 | entropy |
穴を持たないメンバーはここに無い。 std:fs はランタイム層が標準ライブラリで直に読むので
rthost を要さない。
process を落とすと止まらずに答えが違う。 穴が未配線でも取り消しは Child::kill() へ
落ちるので、プログラムは走り切って違う結末を返す — 他の 3 つのように失敗を値で返す形にすら
ならない。第 1 段が最も避けねばならない形なので、他と同じ表に載せる。
判断の場所は 1 つでなければならない。 生成側が rthost::install() を出力するかどうかと、
雛形が rthost を足すかどうかを別々に決めていたところ、適合ハーネスが先にそれを踏んだ —
配線だけがあって依存が無い生成物は E0433(未解決のモジュール)でビルドできない。生成側の答え
(HostFeatures)を雛形が引く形へ直してある。雛形は compiler/cli が持つ。
--offline はそのときだけ外す。 外部クレートを 1 つも足さないという約束はコアのもので、
rthost を引く生成物はレジストリへ出る必要がある。引かない生成物は従来どおり --offline で
ビルドして、出ようとしたらそれ自体が約束の破れであるという見張りを残す。
受け入れは実物で測る。 その能力を使うプログラムが生成物としてビルドでき、オラクルの AOT 生成物と
標準出力・exit code が一致すること。
MATCH の数だけを見ない
モジュール表・variant・pow を入れたとき、MATCH は 3 のまま動かなかった。止まる位置が
ModuleReady や MakeVariant から StdValue や TypeRef へ深くなっただけである。
これは進んでいないことを意味しない — 同じプログラムがより奥まで走るようになり、次の壁が
見えたということである。
型値と文字列補間を入れたときも同じ形が出た。TypeRef の 6 本と Concat の 2 本は入口ごと
消えたが、MATCH は 1 本しか増えなかった — 残りは StdValue(34 件へ増えた)や構造照合と
いう次の壁へ移っただけである。入口が消えたことと MATCH が増えることは別に数える。
構造照合を入れたときは 5 本の入口が消えて MATCH が 2 本増えた(4 → 6)。残りはメソッド
ディスパッチと部分適用という、さらに奥の壁へ移った。部分適用と refinement を入れて 7 になり、
第 1 段の入口はすべて消えた。8 本分の入口が消えて MATCH は 4 本しか増えていない — この
コーパスに残る壁は第 4 段だからである。
MATCH が動くのは最後の壁が取れたときだけなので、進み方は「止まった入口の分布」で読む。
このコーパスでは StdValue(第 4 段)が取れるまで MATCH はほとんど増えない。
判定は共有する。平坦化するかは lirblock.HasCall、自己末尾再帰かは lirblock.HasSelfTail を
引く — 判定を生成側ごとに書き写さない(末尾位置の判定が実行系で割れると、同じプログラムが
一方だけでスタックを溢れさせる)。
第 1 段を超えるものは踏むと止まる。実コーパスで到達する壁は無くなった(前節。残る 1 件は
処理系そのものを要求するので段では埋まらない)。コーパスが撒いていない綴りとしては capture
と、lowering が作らないため到達しない二項演算子 or が残る。止まった名前がそのまま次に
実装すべきものになる。
値の検分表示は綴りまで一致させる。 第 1 段が record / list / tuple の一致を見る手段は
print であり、表示はそのままプログラムの出力になる。並びや区切りが 1 文字でも違えば MISMATCH になるので、表示の規則は VM が持つものを引き写す —
生成側で独自に決めてはならない。
同じ理由で、VM が持たないものを持たせない。多く持たせると「VM が止まるところで生成物が
通る」向きの食い違いになり、出力の突き合わせでは捕まらない。
性能は min(Rust) / min(VM) の比で見張る。分母を VM に採っているので、実行系を足すたびに
キャリブレーションベンチを増やす必要が無い(分子を差し替えるだけで済む)。指標の設計と分解能の限界は ../../bench/README.md にある。
7. スコープ外 (第 1 段)
Rust 生成が低レベル化できる構文は段階的に広げる。低レベル化できない構文に当たった場合、ビルドは
同梱モードを案内して停止する — 黙って壊れたコードを出力したり、勝手に生成先を
低レベル化したりしない。
第 1 段が低レベル化しないものと、それぞれの見通しを挙げる。
7.1 回収器
アリーナは回収器を持つ(§2.1)。第 2 段でフレーム鎖をルートとする精密 mark-sweep を足した。
スタック走査も保守的 GC も要らない — ルートは走っているフレーム鎖と、値を握るモジュールが
差し出す口の 2 系統だけで、そこから箱の中身を辿れば全部に届く。
ルートの出所は登録簿が持つ
回収器は「どのモジュールがルートを持つか」を知らない。値・箱の添字・走っていないフレーム鎖を
握るモジュールが自分で登録簿へ名乗り出て、回収はその登録簿を回すだけである。
回収器がモジュールを数え上げる形にしないためである。 数え上げると、値を握るモジュールを
1 つ足すたびに回収器の側を触ることになり、触り忘れがそのまま回収漏れになる — しかも漏れた値が
掃かれるのは作った瞬間ではなく次の安全点なので、落ちる場所が原因から遠い。登録簿なら、値を
握る側と名乗る側が同じファイルに並ぶ。
名乗るのは値を握る静的領域の初期化からである。 静的領域へ値を入れるには必ず初期化を通るので、
その位置で載せておけば「載せる前に回収が走る」時点が存在しない。
走っているフレーム鎖だけは実引数で受ける。 呼び手が手に持っているものなので、回収器の側から
取りに行けない。
提供者の一覧はここに書かない — 登録漏れは実装側の試験が綴りで見張るので、一覧を散文へ写すと
片方だけが古くなる。
列挙できる形になったのは呼び出しの分割方針の帰結である(§2.4)。自己末尾再帰を除く
すべての呼び出しが再開点を通るので、フレーム鎖はトランポリン 1 回の呼び出しに 1 本だけ
できる。入れ子のトランポリンがホストのスタックへ鎖を散らす形は無い。
割り当てでは回収しない — 回収は安全点でだけ走る
アリーナは割り当ての最中に借用されているので、そこから回収を始めることはできない。そこで
起動を割り当てから切り離す。割り当ては「回収が要る」フラグを立てるだけで、回収そのものは
安全点でだけ走る。安全点は 2 つしかない。
| 安全点 | そこで生きている生成側の状態 |
|---|---|
| トランポリンのループ頂上 | 生成コードは 1 つもホストのスタックに乗っていない |
| 生成関数の後退辺 | 局所値はすべてフレームのスロットにあり、フレームを手に持っている |
この 2 点以外で回収は起きない。 ホストのローカルに値が乗っている位置には安全点が無いので、
そこがルートから漏れる形にならない。
走っているフレームだけが鎖の外にある
鎖はトランポリンのローカルからスレッドローカルへ移す。トランポリンが鎖を触るのは生成
関数を呼ぶ前と後だけなので、回収器が生成関数の実行中に鎖を借りても衝突しない。
不変条件は 1 つである — 走っているフレームだけが鎖の外にあり、それは回収器へ実引数として
渡る。トランポリンの頂上では現フレームを、後退辺では生成関数が持つフレームを渡す。
unsafe も生値ポインターも値表現の変更も要らない。
トランポリンの再入は禁止する。 今も再入しないが、黙って再入すると鎖が上書きされてルートの
列挙が崩れる。番兵を置き、踏んだら名前を出して止める。
安全点の前提は「局所値がスロットにある」ことである
安全点が呼び出しの経路にしか無いと、呼び出しを 1 つも含まない割り当てループが回収を一度も
踏まない。反復ごとに record を作り捨てる形はコーパスに実在するので、後退辺は平坦化の有無を
問わず安全点にする(自己末尾再帰の戻りとループの戻りの両方)。
妥当性の条件は平坦化そのものではなく、その関数の局所値がすべてスロットにあることである。
§2.4 は局所値をホストのローカルへ載せることを許しているが、生成側はまだ行使していない。
前提は散文ではなく検査で守る。 ホストのローカルへ載せた関数に後退辺の安全点が同居したら
生成を失敗させる。局所化を入れる日には、その関数を平坦化へ倒す(値がスロットへ戻る)か
安全点を落とす(その関数が回収を踏まなくなる)かを選ぶことになり、黙って不健全にはならない。
回収器は差分検証で受け入れられない
適合ハーネスが突き合わせるのは実行の出力と exit code だけなので、回収器が 1 バイトも返さなくても
MATCH する。したがって自前の基準で受け入れる。向きの違う 3 つの欠陥を、別々の欄で捕まえる。
| 欠陥の向き | 症状 | 捕まえる欄 | 入口 |
|---|---|---|---|
| 回収しすぎ(生きている箱を回収した) | 誤答・異常終了 | 墓標を踏んだ読み出し | シャドウ検証 |
| 生かしすぎ(死んだ箱を回収しない) | 黙って効かない | collected(回収した箱の数) |
割り当て契約 |
| 再利用しない(掃いた添字を配り直さない) | 掃いているのに伸び続ける | arena_len(アリーナの実長) |
割り当て契約 |
どれか 1 つでは受け入れられない。墓標は「効いたか」を測らず、collected は「壊したか」を
測らない。
3 つ目は前の 2 つの死角である。マーク付けも掃きも計数も正しいまま、空き添字を配り直すのを
やめた場合を考える。boxes(割り当ての累計)も live_peak(生存の高水位)も collected
も cycles も墓標の数も1 つも動かない。動くのはアリーナの実長だけで、それは割り当ての
総数まで伸びる — 第 2 段が消すためにある症状そのものが、他の欄すべてを緑にしたまま戻る。
シャドウ検証からも見えない(検証モードは意図して添字を再利用しないので、そちらでは伸びるのが
正しい姿である)。したがって arena_len は通常モードで採る。
生かしすぎを捕まえるのは live_peak ではない
生かしすぎを見張る欄は collected(回収した箱の数) であって、live_peak ではない。
余分に生かした箱はその回の sweep から漏れるので、掃く数がその分減る。生かしすぎが
毎周期そこにあり続けるなら(辿ってはならない辺を辿る類の欠陥はそうなる)、終了時の累計
collected に必ず差として出る。掃きを遅らせるだけの生かしすぎは出ない — 次の周期で
掃かれるなら累計は変わらないので、collected が捕まえるのは持続する生かしすぎである。
live_peak では捕まらない。live_peak は「生きている箱の最大数」だが、次の回収を要求する
しきい値は起動の下限(GC_MIN)で床を持つので、真の生存数が下限より小さいコーパスでは
live_peak が下限に張り付く。張り付いた値は、下限に届かない範囲の余分な生存をそのまま
飲み込む。実測でも、型値をアリーナの添字として辿る欠陥(下記の基準 6 の最後のもの)は
live_peak を 1 桁も動かさず、collected にだけ差として現れた。
cycles(回収を走らせた回数)は床ではなく、向きが反転することで使えない。 しきい値は
回収のたびに max(GC_MIN, 生き残り × 2) へ据え直すので、1 周期あたりに使える割り当ての余地は
「しきい値 − 生き残り」である。
- 床が効いている間(
生き残り × 2 < GC_MIN)、余地はGC_MIN − 生き残りである。
持続する生かしすぎは生き残りを増やし、余地を狭め、cyclesを増やす。 - 床を越えると余地は
生き残りそのものになり、今度は生き残りが増えるほどcyclesは
減る。
同じ量が同じ向きの欠陥に対して増えたり減ったりするので、cycles の増減からは生かしすぎの
有無も大きさも読めない。加えて、小さく有界な生かしすぎは差が丸められて出ない — 型値を
アリーナの添字として辿る仕込み(下記の基準 6 の最後のもの)で cycles は 293 のまま
動かなかった。基準には併せて載せる(起動の回数が変わったこと自体は知りたい)が、
生かしすぎを見張る欄として数えてはならない。
しきい値を下げても解決しない。 下限を下げれば床が下がるだけで、真の生存数が新しい下限
より小さいコーパスでは同じことが起きる。飲み込むかどうかは下限の大きさではなく「真の生存数が
下限に届くか」で決まるので、下限をいくつにしても飲み込む余地は残る。下げれば回収が頻繁に
なって遅くもなる。床を持たない量で見張るのが筋であり、その床が無いのは collected
だけである。
墓標 — 回収したはずの箱を読んだら止める
検証モードでは sweep が箱を解放も再利用もせず墓標へ置き換える。以後その添字を読む経路は
すべて照合地点になり、踏んだら名前を出して止める。
オラクルは回収器自身ではなくプログラムの実際の読み出しである。回収器が「死んでいる」と
判定した箱をプログラムが後から読んだなら、その判定が誤っていた。これは型消去のシャドウ検証が
「消したはずの位置に照合を残す」のと同じ形である。
計数は 3 つ(墓標の数・検分した読み出しの数・回収を走らせた回数)で、出す先は stderr で
ある。ただし突き合わせに混ざらないことを守っているのは出し先ではない — 適合ハーネスは
stdout と stderr の両方を比べるので、常時出せばどちらへ出しても採取のための数が全件の判定に
混ざる。守っているのは「既定では 1 行も出さない」ことの方で、stderr を選ぶのはプログラム
自身の答えと採取のための計数を読む側で分けるためである。
検分そのものは通常モードでも走る
墓標を作るのは検証モードだけだが、読む前の検分は両モードで走る。通常モードで外しては
ならない。外すと解放済みの箱が「未対応の箱」を受けるアームへ落ち、回収器の欠陥が
「まだ書いていない」(exit 70)として適合ハーネスに分類される。それは段の切り方を数える
欄なので、回収器の破れがそこに紛れると誰も見ない数が 1 増えるだけになる。検証の破れに
専用の exit code を持たせたのは分けるためであり、通常モードで検分を落とすとその設計を
内側から無効にすることになる。
代価は実在する。箱を読むたびに検分の計数(1 回の借用)と生死の照合(アリーナへの再アクセス
と分岐)が読みそのものに上乗せされる。これを値付けする入口は時間の契約だが、そちらは
基準が未採取の間非活性である(下記の基準 9)。値付けできるようになるまで、この代価は
「測っていない」であって「小さい」ではない。
照合地点が 0 件なら失敗させる(空虚な成功を防ぐ)。墓標が 0 でも、検分した読み出しが 0 でも、
報告そのものが無くても失敗である。
検証はクレート試験とコーパス実行の両方で回す。ルート(走っているフレーム鎖と、登録簿に
載った口)から箱の中身の 3 種(スロット・閉包の束縛と捕捉・Cell の中身)へ辿れることは
クレートへ直接当てて確かめられるが、生成側の配線
(後退辺に安全点が出ているか・走っているフレームが渡っているか)はクレート試験では原理的に
触れない。Hikari プログラムを通さなければ、生成がルートを見せ損ねても緑のままになる。
踏んでいないルートについて検証は空虚な成功である
コーパス実行が意味を持つのは、ルートの各種類をコーパスが実際に踏んでいるときだけである。
import を持つプログラムが 1 本も無ければモジュール表は常に空で、そのルートを落とす欠陥は
何も動かさない。部分適用を行うプログラムが無ければ閉包の bound は常に空で、同じことが
起きる。クレート試験が緑でも、生成側がルートを見せ損ねたことは分からない — それがコーパス実行の
存在理由だからである。
踏ませ方には注意が要る。そのルートを辿る以外に生存を保てない形にしなければ、値がフレームの
スロットに残ったまま偶然生き延び、欠陥を仕込んでも捕まらない。実際に効いた作りは次のとおりで、
どれも「値を作ったフレームを先に捨ててから安全点をまたがせる」形である。
| ルート/辺 | 踏ませ方 |
|---|---|
| フレーム鎖 | 反復ごとに記録を作り捨てる(既存の系列がすべて踏む) |
| モジュール表 | 実体を持ち帰らない別モジュールに先に評価させ、回収を走らせた後で同じモジュールをもう一度取り込む |
Cell の中身 |
別関数越しに Cell の中身を読む(読んだ値が呼び手のスロットに残らない) |
閉包の bound |
別関数で作った記録を部分適用で埋め、その関数が戻ってから安全点をまたぐ |
閉包の captures |
別関数で作った記録を捕獲する閉包を返させ、走っていない状態で生かしたまま回収をまたがせ、後から呼ぶ |
captures には固有の注意がある。呼び出し規約が捕獲をフレームへ写すので、走っている閉包の
捕獲はフレーム鎖から再度ルートになる — 走らせながら踏ませても、捕獲を辿らない欠陥は捕まらない。
閉包を持ったまま回収をまたぎ、後から呼ぶ形にする必要がある。
踏んでいることの確かめ方は仕込みである。 ルートを 1 種類ずつ壊し、コーパス実行が exit 71 で
止まることを見る(下記の基準 6)。止まらないなら、そのルートはコーパスが踏んでいない。
文字列アリーナも同じ mark-sweep で掃く
回収器は箱のアリーナと文字列のアリーナの両方を掃く。文字列は別のアリーナに載る(§2.1)
ので、マーク表を 2 つ持ち、値の種別で振り分ける。箱の添字と文字列の添字は別の空間なので、
1 つの表に混ぜると片方が他方を無関係に生かしてしまう。
- ルートの列挙は共通である。 走査は箱だけで進む — 文字列は辿る先を持たない葉なので、
マークを付けるだけでよく、ワークリストへは積まない。 - 文字列の即値を持つ種別は 2 つである。
Strと、移譲のシャドウ検証のポイズン値であるMoved。
後者を辿り落とすとポイズン値の綴りが掃かれ、診断が読めなくなる。 - 通常モードは freelist で添字を再利用し、検証モード(
HIKARI_VERIFY_GC)は墓標を残す。
読む前の検分も箱と同じで、通常モードでも走る — 外すと回収器の欠陥が「空文字列を
読んだ」に化けて、原因から遠い場所で初めて見つかることになる。
起動条件は箱と別に数える。 箱をほとんど作らずに文字列だけを伸ばすプログラム
(acc = acc + s の反復)があるので、箱の生存数だけを条件にするとそこで回収が 1 度も
走らない。生存する文字列の数にも同じしきい値(生き残り × 2・下限 GC_MIN)を持つ。
帰結として、作業量に比例した文字列を作り続けるプログラムも際限なく太らない。補間
${…} を含む末尾再帰ループは反復ごとに 2 つ積むが(リテラル断片と連結の結果)、届かなく
なった枠は掃かれて再利用される。
割り当て契約はこの列を strs として載せる。載っているだけで動かない列は何も見張らない
ので、文字列を積む系列をコーパスに 1 本置いて strs を生きた数にしてある。その系列は
補間を使う — リテラルだけの文字列は文字列定数の経路しか踏まず、実際に文字列を伸ばす
連結の経路が契約に入らないからである。
strs は累計なので実長ではない。 添字を再利用しなくなった欠陥は strs にも
strs_collected にも出ないので、契約は実長を strs_arena_len として別に載せる — 箱の側で
arena_len が受け持つのと同じ 3 つ目の向きである。
起動条件は決定的でなければならない
割り当て契約は数を厳密に比べる(../../bench/README.md)。回収の
起動が時間やメモリ圧に依ると live_peak も collected も揺れ、ゲートごと形骸化する。起動は割り当て数だけで
決まるものにし、同じプログラムを 2 回走らせて同じ数が出ることを採取の前提として確かめる。
第 2 段の受け入れ
| # | 基準 | 見張る入口 |
|---|---|---|
| 1 | boxes が第 1 段と 1 つも違わない(回収器は割り当てを増やさない) |
aot-alloc-check |
| 2 | live_peak が全系列で反復数に依らない定数へ落ちる |
aot-alloc-check |
| 3 | collected が基準と一致する(持続する生かしすぎがここに出る。cycles も併せて載せる) |
aot-alloc-check |
| 4 | arena_len が基準と一致する(掃いた添字を配り直さなくなるとここにだけ出る) |
aot-alloc-check |
| 5 | 墓標が 0 件でなく、検分した読み出しも 0 件でない。系列ごとに見る | verify-gc |
| 6 | ルートを 1 種類ずつ壊すと必ず落ちる | verify-gc と aot-alloc-check |
| 7 | MISMATCH が 0 のまま |
aot-conformance |
| 8 | 同じプログラムの 2 回の実行で数が一致する | aot-alloc-check |
| 9 | 時間が退行しない。この行は今非活性である(上記「検分そのものは通常モードでも走る」と ../../bench/README.md) |
bench-aot-check |
基準 2 の定数は起動のしきい値で決まるので、実装して初めて分かる。実測して基準へ commit する —
その時点で records の 400,003 が定数レベルへ落ちていなければ第 2 段は未達である。
基準 3 の 2 欄は通常モードで採る。 検証モードは添字を再利用せず墓標を残すので、生存の
振る舞いも live_peak の意味も変わる。同じ理由で、通常モードでも回収の計数を出す。
基準 6 は仕込みで確かめる。モジュール表をルートから落とす・閉包の束縛(bound)を辿らない・
閉包の捕獲(captures)を辿らない・Cell の中身を辿らない、の 4 つは墓標が捕まえる
(辿り落としはそのまま回収しすぎになる)。型値の即値をアリーナの添字として辿る欠陥は
生かしすぎの向きなので墓標にかからず、割り当て契約の collected が捕まえる(型値は表への
添字であって箱ではない・§2.5)。
仕込みは 1 つずつ入れて 1 つずつ戻す。 複数を同時に入れると、どの入口がどれを捕まえたのか
分からない。クレート試験が落ちたことを墓標が捕まえたことにしない — 見るのはコーパス実行が
exit 71 で止まり「回収済みの箱を読んだ」と名乗るかどうかである。
7.2 中断とスケジューラー
第 3 段で fork / spawn / sleep / wait_any / all / race と Future の合成
(map / and_then / timeout / is_ready / cancel)、そして未解決 Future への ! を
足した。Step::Suspend に到達点ができ、フローの鎖が回収器のルートに加わった。生成物も同じ道を
通る — 生成側は制御に参加する組込を値として配るだけで、1 行も変わらない(§6)。
中断は「フレーム鎖を持ち替える」ことである
フローはフレーム鎖を持つ値で、スケジューラーはそれを順に回す。走っているフローの鎖は
トランポリンのスレッドローカルにあり、中断したフローの鎖はスケジューラーが持つ。切り替えに
実行スタックの切り替えは要らない — 生きた状態がすべてフレームにあるという §2.2 の約束が、
そのまま中断の実装になっている。
増えたのは到達点だけである。 集合を先に閉じておいた(§2.2)ので、Step を受ける
トランポリンの分岐は 1 つ埋まっただけで、生成側は 1 行も変わらない。
中断できるのは底の鎖だけである
入れ子の鎖(== / compare スロットと順序の口が借りる call_nested)は中断できない —
戻り先がホストの関数だからである。言語も同じことを定めている(== / compare の本体は
中断してはならない)ので、ここは規則の実装であって制限ではない。踏んだら名前を出して止める。
スケジューラーは入れ子になる
プログラムからテストを走らせ直す口(test.run)があるので、走っている鎖の上でもう 1 つ
スケジューラーが立つ。外側の鎖はスケジューラーが預かる(駐機)。預けた鎖も回収器のルートで
あって、ルートから外すと内側の回収で外側のフローの足元が掃かれる。
かつて置いていた「トランポリンの再入を拒む番兵」は、この駐機に置き換えた。番兵が守っていた
不変条件(鎖が上書きされてルートの列挙が崩れないこと)は駐機の方が直接に守る。
停止はフロー境界で止める
別フローの停止はそのフローの Future が運び、! で取り出した消費側へ伝播する
(docs/specs/language-spec.md §10.2)。したがってスケジューラーは 1 回の実行スライスごとに停止を
捕まえ、診断にはしないで Future へ載せる。捕まえずに抜けると、await されていない
停止までプログラムを落としてしまう。
診断は止まったその場で組む。 呼び出し境界はフレームの鎖から引くので、巻き戻した後では
もう引けない — Future へは本文だけでなく組み上がった診断文ごと載せる。
並列に走らせなくても spawn は適合する
spawn の隔離は捕獲した可変状態の複製で与える。単一スレッドも適合する実装である
(§10.1) ので、隔離した子は協調領域でそのまま走らせている。眠りはスケジューラーが 1 つの期限へ
まとめるので、「3 本の sleep 300 が 300ms 強で終わる」形は協調でも成り立つ。
CPU バウンドな仕事を重ねる形(examples/spawn_parallel.hika)はここで差が出るが、その
プログラムは壁時計を印字するので、オラクル同士でも一致しない — 数える側が「非決定の口を
踏んだ」と分類する対象である。
移譲のマークは捕獲と一緒に運ぶ
spawn は移譲と判定された捕獲を複製せず、非 Copyable 検査も飛ばす
(docs/specs/prelude.md §9.9)。マークは LIR の MakeClosure が持っているので、閉包の付随する欄へ
載せて運ぶ。マークを落とすと、移譲した非 Copyable が「複製できない」として拒まれる — 静的
検査が通したプログラムが実行時にだけ落ちる形になる。
新しい入口として足す。 既にある構築の入口は rustgen が出力する Rust からも呼ばれるので、
シグネチャを動かすとそちらがビルドできなくなる。
継続はフレーム鎖の切り離しである
効果ハンドラー(handle、docs/specs/language-spec.md §17.9)も同じ前提の上に立つ。局所値は
すべてフレームにあるので、「操作の呼び出し地点からハンドラーまで」の区間は鎖を 1 本切る
だけで取り出せる。取り出した鎖が継続で、再開はハンドラーを張り直して鎖を繋ぎ直し、切れ目の
フレームへ戻る。
横取りは呼び出し規約の側で行う。 操作の側(println / fs.read …)には手を入れない —
トランポリンが呼び先のフレームを作った直後に、設置済みハンドラーを 1 度だけ見る。設置が無ければ
整数の比較 1 回で素通りするので、handle を書いていないコードの経路長は変わらない。
切り離した鎖は誰からも辿れない。 回収器のルートに加えないと、再開する前に掃かれる。控えている
継続が掴む操作スロットも同じである。
規則は 3 つある。どれも「答えは正しいままなので、出力の突き合わせでは捕まらない」種類の
ものなので、ここに書き出しておく。
- 操作スロットを走らせている間、そのハンドラーは設置されていない。 操作スロット本体が起こす効果は
handle式の効果として外へ残る (docs/specs/language-spec.md §17.9)。 - 継続を呼ばない形に専用の経路は無い。 操作スロットが普通に値を返しただけであり、取り出した鎖は
捨てられる (AtMostOnceは使い残しを咎めない)。 - 鎖を捨てるときは、その中のハンドラーも降ろす。 捨てる区間に
handleが混じるのは、内側の
ハンドラーの操作スロットが起こした操作を外側が受けた形である (操作スロットを走らせている間内側は
設置されていないので、その効果は外へ出る)。そのフレームは二度と走らないので自分では後始末
できず、降ろさないと設置数が戻らないまま「ハンドラーが 0 個なら素通り」が効かなくなる。
Step は 6 つだった
Resume(継続を繋ぎ直して内側から走らせ直す)は既存の 5 つのどれとも別の遷移である —
Call は鎖を 1 段伸ばし、Tail は積み替え、Return と Unwind は戻る向きだからである。
オラクルのランタイム層も同じ 6 つを持っており、§2.2 の表が 5 つで閉じていたのは写し損ね
だった。集合を閉じておく効果はここでも効いた — 足したのは到達点と 1 変種で、生成側は
1 行も変わっていない。
外部待ちは呼んだ地点では読まない
input(prompt) は Future(Result(String)) を返すだけで、実際に読むのは ! を当てた地点で
ある (docs/specs/prelude.md §9.4)。待たれない入力を読みに行くと、プロンプトだけが出て応答が
捨てられる。読み手は 1 つである — 読むたびに緩衝を作ると、先読みされた次の行を捨てる。
exit はプログラムを終える口であってプロセスを落とす口ではない
終了要求はトランポリンの外まで巻き戻り、終了コードをどう扱うかは入口が決める。両者を
同一視すると、処理系に埋め込んで走らせる側がプログラムに道連れになる。
停止とは別系統である。 停止はフロー境界で捕まる(未 await の panic はプログラムを
落とさない)が、終了要求はそこで止まってはならない — 捕まえる側が停止だけを降ろして他を
投げ直すので、別の型にしておけば追加の細工は要らない。
hikari test の本体が exit を呼ぶ形は、オラクルでは未処理の panic になって処理系ごと
落ちる。写す相手が無い(出力が処理系の stack trace になる)ので、この実装は入口で受け止めて
終了コードにする — 突き合わせでは踏めない位置である。
7.3 ホスト能力と標準ライブラリ
std: の実装は第 4 段で、必要な範囲だけを順に足す。段として閉じない — 能力 1 件ごとに
「MATCH + 契約の内側か外側かの記録」で受け入れる。SQLite のように C 依存へ戻るものが含まれる
が、契約の対象外なので第 1〜3 段を止める理由にはならない。
生成側は既に綴りごとの表を持ち、メソッド供給も展開済みループも中断も落ちる(§6)ので、
std: の値とその先の呼び出しは走る。ホストの穴を埋める実装も、実コーパスが撒く 4 つ
(SQL エンジン・端末の生 I/O・TLS 終端・子へのシグナル)は生成物へ引き込んである。
第 4 段に残るのは、コーパスが撒いていない能力を踏んだときに足す実装だけである。
生成物は使う能力の穴だけを引き込む。 依存は既定ではランタイム層のコアだけで、穴を要する
能力を使ったときだけ実装(rthost)を feature 付きで足し、生成した入口が rthost::install() を
呼ぶ(§6 の「ホスト能力は使うプログラムだけが背負う」)。引き込まないと未配線のまま「持たない
ビルド」として振る舞い、穴を埋めて走る VM と違う答えになる。
引き込む生成物だけがレジストリへ出る。 外部クレートを 1 つも足さないという約束はコアの
ものなので、能力を使わないプログラムのビルドは従来どおり --offline の見張りの下にある。
std:process — 受け入れ済み
run / exec / run_opts を足した。外部クレートは要らなかった — 起動・作業ディレクトリ・
環境変数・標準入力・標準入出力の継承、そして新しいプロセスグループ(process_group)まで
ホストの標準ライブラリが持っている。
起動はその場で行い、解決済みの Future を返す。 仕様上の戻り値は Future だが、
オラクルも並列を持たないホストではこの枝を採る。その帰結として、走行中の子プロセスは
キャンセルできず、並行に起動した 2 本も実時間では重ならない — どちらも意味論ではなく順序の
話で、単一スレッドも適合する実装である(§10.1)。重ねるには「外の事象が Future を
解決する」窓口が必要で、それは term.read_key や http と共通の次の道具立てである。
シグナルの綴り(terminated / killed …)だけは番号から引き直している。分岐は kind で
行うので、そこは診断の本文にしか現れない。
std:parallel — 受け入れ済み
map / filter / fold を足した。逐次に走らせても契約は満たせる —
std/parallel.md が約束しているのは「結果と順序が逐次版と同一であること」と
「隔離の粒度がチャンクであること」の 2 つで、どちらも実行が実際に重なるかどうかに依らない。
重ならない実装は、仕様が既に持っている「1 チャンクへ丸めたら逐次評価へ倒す」枝が常に選ばれた
形である。
隔離はチャンクの境目でだけ行う。 要素ごとに捕獲を複製し直すと、同じチャンクの中で可変
状態を累積する fn の見え方が変わってしまう。境目を跨ぐときにだけ複製すると、チャンクという
粒度がそのまま観測できる。
隔離できない捕獲は要素へ入る前に落とす。 非 Copyable の捕獲は呼び出し自身の失敗であって
最初の要素の失敗ではない。後ろへ倒すと、空リストが黙って通る。
fold の 2 相(チャンクごとに種無しで畳み、その部分結果だけを初期値付きで畳む)は逐次へ
倒しても省けない。初期値をチャンクの種にすると結合律だけでは足りず単位元性まで要求することに
なり、チャンク数を変えると答えが変わる。畳む順序は実装の都合ではなく面の一部である。
末尾の chunks は組込の側が既定値(省略時 0 = 自動)を持つ形で足した。既定値は呼び出しの面に
現れるので、生成側ではなくランタイム層の入口が持つ。
std:db/sqlite — 受け入れ済み
open / query / exec / transaction / close を足した。ここから先は外部クレートを
持つ(§5)— オラクルも SQL エンジンには外部モジュールを使っており、この実装だけが 0 本を
守っても得られる性質が無い。
置き場所で契約の内側と外側を分ける。 判断(プレースホルダーの数え上げ・受理集合・列名の
検査・値と Option の対応)はコアに、SQL の実行だけを外側のクレートに置く。オラクルの rt と
rt/host/db の分け方をそのまま写した形で、生成物が引くのはコアだけなので、この能力を使わない
プログラムはエンジンを背負わない。
穴は関数ポインターと不透明な番号で開ける。 オラクルは「関数の欄を持つ構造体」で接続を渡すが、
この実装は trait object を持たない(§3)ので同じ形は採れない。開いた接続とトランザクションを
ホストが採番し、コアは番号だけを持つ — 既にある「記述子は採番した側が持つ」規律と同じ形である。
待たずに busy にする。 オラクルは接続をプールから取るので、トランザクションが握って
いる間の直接の query は待ってから期限超過で busy になる。この実装は単一スレッドなので
待っても相手は進まない — 同じ Err(busy) を待たずに返す。std:process の「起動はその場で
行う」と同じ分類である。
行の記述子だけが実行時に決まる。 列名は SQL 文の中にあり、生成時には確定しない。スロット名の
interning は生成時に済んでいるという規律の唯一の例外で、そこだけ実行時に採番する。プログラムが
持たない名前(どこからも r.<name> と書かれていない列)には生成時の採番の続きを与える — 引く側の
綴りが無いのだから衝突しない。
巻き戻しでも必ず終わらせる。 block が停止するとフレーム鎖はそこで捨てられるので、
transaction の後半(commit / rollback を決める段)へは戻ってこない。開いたままの
トランザクションは、フローが停止で終わるところで取り消す。
std:term — 受け入れ済み
raw_mode / read_key / peek_key / size を足した。termios と select のためだけに
libc を引く(§5 の 2 つめの位置)— オラクルは標準ライブラリで同じ ioctl を叩いており、
Rust の std にはその面が無い。落とす欄も待ち方もオラクルと同じである。
外の事象が Future を解決する最初の窓口である。 read_key は未解決の Future を返して
保留読みへ積み、スケジューラーが「走れるフローが尽きた地点」で解く。std:process や
std:db/sqlite が採った「その場で行って解決済みを返す」枝とは違い、ここは実際に中断する —
入力を待つ間他のフローが走る。
待ちの期限は 2 つの早い方である。 自分のタイムアウトと、最も早い時刻待ちフローの起床の
どちらか早い方で区切る。後者で区切らないと、入力を待つ間 sleep が遅れる。区切られた
だけなら保留は積まれたまま残り、次の周回で読み直す。
期限切れはバイトを 1 つも消費しない。 可読になってから読むので、期限切れの経路は read を
呼ばない — 単押しの ESC と多バイト列の判別がこれに依る。
復元は番号で持ち、巻き戻しでも実行する。 元の termios はホストが控えて番号で引く(関数
ポインターは捕獲を持てない)。停止で抜けたときはフレームがもう無いので、巻き戻しの経路が明示的に
端末を戻す — 戻さないとシェルが raw のまま残る。
std:hikari と std:hikari/doc — 受け入れ済み
highlight と entries / generated_notice を足した。外部クレートは要らない — どちらも
処理系そのものの能力で、この実装の処理系(compiler)が既に持っている。
埋めるのは実行系であって、コアではない。 コアが持つのは穴の読み出し口だけで、分類器も
リファレンスデータも VM が入れる。AOT が持てないのは設計である — 生成側は綴りを持たない
ので「未対応の標準ライブラリの値」で止まる。オラクルと同じ非対称をそのまま写した。
リファレンスデータは抽出せず、処理系が持つ。 表は doc ブロックから抽出した成果物で、
実行時に読むものではない(抽出はディスクを読むので、配ったバイナリでは走らせられない)。
はじめはオラクル側の生成物を焼き直していたが、焼き直し器は撤去した — 表そのもの
(compiler/docdata/src/table_gen.rs)が処理系ソースであり、出所である。表とツリーの
食い違いは docdata クレートの鮮度試験が名指しする。
シグネチャ表もツリーを読まない。 hover / 補完 / リファレンスが引く形と一行の意味は、この表から
組む(prelude::docsigs / stdlib)。配ったバイナリ 1 つで答えられるので、処理系ソース
ツリーの外で開いた .hika でも hover が沈黙しない。ツリーが要るのは定義ジャンプの位置索引
だけである。
種別と検証レベルは公開語彙で持つ。 内部定数の綴りではなく syntax / library・
verify / run_only / parse_only で出力するので、読み出す側は綴りをそのまま値にするだけで
よい。
表は呼ばれるたびに組み直す。 オラクルは 1 度組んで共有するが、この実装でそれをやると
組んだ値を回収器のルートに据える必要が出る。レコードはすべて不変なので、組み直しても呼び手から
見える事実は変わらない — 費用だけの話である。
std:hikari/repl — 受け入れ済み
session / eval / check / needs_more を足した。写した処理系が自分で書いた
プログラムを走らせる最初の口である — self-host の形がここで一度成立する。
セッションは表であってフレームではない。 入力ごとに top-level 束縛が増え、前の入力の
束縛を後の入力が参照する。フレームは大きさが固定なので置けない。モジュール表と同じ
「キーが生成時に確定した整数の表」にすれば足り、名前引きは実行時に現れない。
捕獲ではなく共有である。 閉包がセッション束縛を参照する形は捕獲ではなく表の読み出しに
なる。a = 1 → g := {| a } → a = 2 → g! は 2 を返す — 呼んだ時点の値を読んで
おり、捕獲していない。
退いた束も回収器のルートである。 オラクルはホストの回収器が束を生かすが、こちらは自前の
回収器なので、差し替えて退いた束をルートから外すと次の入力が読めない箱を読む。したがって束は
側の表が控え、番号で引く(接続ハンドルと同じ形である)。モジュール表も一緒に差し替える —
差し替えないと外側のプログラムの評価済みモジュールがセッションから見える。
読み込みは積む。 セッションは走っているプログラムの中から 1 入力を読み込み直すので、
読み込み済みの表を 1 本にすると外側の定義が引けなくなる。rt 側の添字は読み込みごとに前へ
詰めて伸びるだけなので区間は重ならず、どの読み込みのものかは区間で決まる — 前の入力が
作った閉包も生きたままである。
検査にはセッションの束縛を渡す。 検査が見るのは 1 入力分の program だけなので、渡さ
ないと入力をまたいだ重複宣言(x := 1 の次の x := 2)と immutable への代入(x = 2)が
素通りする。運ぶのは名前と可変性だけで、型は運ばない。
std:regex — 受け入れ済み
compile / escape と Regex object の 6 メソッドを足した。エンジンを自分で書いた —
オラクルは標準ライブラリの正規表現エンジンを使うが、それは外部ライブラリではない。
§5 の 2 つめの位置(std の境界が 2 つの言語で違う)にも当たらないので、書けば足りるものは
書く。中身は Thompson 構成の NFA を並列に進める形(Pike VM)で、仕様が保証する線形時間
(regex.md §3)はバックトラックしないことから直に出る。
方言は観測できる契約である。 受理するパターンの集合・最左優先の選び方・空一致の進め方・
群の参加不参加・診断の文面まで、すべてプログラムから見える値になる。したがって「だいたい
同じ正規表現」では足りない — 撒いて突き合わせるまで、違う方言を実装していても緑になる。
ジェネレーターを先に書いた。 文法から組み立てたパターン(壊れた綴りも含む)と対象の対を撒き、
生成側と VM の stdout を突き合わせる。手で選んだ 3,852 対と乱数の 4 種(各 4,000 件)で始めて、
6 つの食い違いを掘り当てた。どれもコーパスの回帰テスト 10 件は通る形である。
splitの末尾の断片 — 最後の一致が対象の末尾に接するとき、オラクルは断片を足さないsplitに空の対象を渡した形 — 空でないパターンなら結果は 1 要素(空文字列)である(a*)*の群が参加しない — 空に一致しうる本体のx*は(x+)?として組む必要がある
(組まないと本体を 1 度も通らない道だけがMatchへ届く)(?i)の効く範囲 — 修飾は囲む群の終わりまでで、|の枝を跨いで効く\11は 8 進エスケープである — 単独の\1だけが後方参照の綴りで、そこはエラーになる- 診断の文面 4 種 — 名前つき群の綴りが壊れた形・繰り返しの引数が無い形・入れ子の繰り返し・
修飾の読めない綴りで、載せる範囲がそれぞれ違う
解析器は明示の積みで書く。 再帰下降にすると群の入れ子がそのままホストのスタックの深さに
なり、構文木の高さを検査する地点へ辿り着く前に落ちる(実測で 1 万段)。オラクルの解析器も
明示の積みで書かれている — 深い綴りに対して同じ答えを出すには、同じ形が要る。
非捕獲の群は構文木に節を作らない。 (?: をいくら重ねても構文木は深くならないので、高さは
捕獲の入れ子だけで決まる(オラクルの構文木と同じ形である)。構文木が深くなりうるのは高さの検査を
通る前だけなので、検査と後始末も明示の積みで行う — 検査そのものが深い構文木で落ちては
意味が無く、弾いた構文木を既定の後始末に任せると落とした瞬間に同じ場所で落ちる。
20 万段まで両側で一致する。 捕獲(高さで弾かれる)と非捕獲(通る)の両方を撒いて確かめて
ある。上限は構文木の高さ 1000 の 1 つだけで、そこは数え方も文面もオラクルと同じである。
撒く形は差分ハーネスへ入れた。 手元で 1 度測って終わりにすると、次にエンジンを触った
ときに同じ観測が要る。clifuzz に枝を 1 つ足し(1 プログラムに 30 対)、cli 種別が
そのまま両方の実行系で走らせて突き合わせる — マークも 1 つ足したので、走った回が 0 なら失敗する。
std:http/client と std:http/server — 受け入れ済み
get / head / delete / post / put / patch / request と serve を足した。
外部クレートは足していない。 オラクルは標準ライブラリの HTTP 実装で賄っており、
§5 の 2 つめの位置が言う「境界を埋めるもの」には当たらない — 標準ライブラリで持っていて
Rust では書けば足りるものは自分で書く。平文の HTTP/1.1 は 3 ファイル(線の層・クライアント・
待ち受け)で足りた。
TLS は外側で埋める。 §5 の 2 つめの位置に当たるので、暗号そのものは書かずに純 Rust の
実装(rustls)を引く。穴の形は他のホスト能力と同じで、コアが持つのは関数ポインターと
不透明な番号だけである — 接続そのものは口の向こうが持ち、コアは平文と TLS を 1 つの口
(Wire)として読み書きする。feature を外したビルドでは https:// への要求と tls を渡した
serve が呼び出しで止まる(持っていない能力を「送れなかった」の顔をした Err に
化けさせない)。証明書が読めない serve は止めずに Err(listen_error) である — オラクルが
bind の前に証明書を読んで分類しているからで、そこは持っていなくても同じ答えを出せる。
ハンドシェイクも 1 巡ずつ進める。 塞いで待つと同じプログラムの待ち受けが動けなくなるので、
読み書きを 1 歩進めるたびにハンドシェイクも進む形にしてある(std:term の保留読みと同じ位置づけで、
外の口はすべて「走れるフローが尽きた地点」でまとめて見る)。
代理は環境変数で決まる。 オラクルの既定の transport は HTTPS_PROXY / HTTP_PROXY /
NO_PROXY を見る(https は CONNECT でトンネルを掘る)。ループバックは代理を通さない
のもオラクルと同じで、これが無いと自分で立てた待ち受けを自分で叩く形が代理へ吸われる。
TLS を埋めるまでこの差は見えなかった — 外へ出る経路が 1 つも動いていなかったからである。
転送の失敗は「どの要求で起きたか」を冠する。 オラクルは Get "<url>": <理由> の形で
包む(送る前に分かるエラー——invalid_url / invalid_request——は包まない)。理由の綴りはホストの
ものなので一致しないことがあるが、包みは HTTP の側の体裁なので写す。
クライアントの失敗は期限切れ以外すべて connect_error である。 オラクルは要求の転帰を
まとめて 1 つの関数で分類しており、ハンドシェイクの失敗も応答を読み切れなかったのも同じ kind になる —
io_error はクライアントからは出ない。
スケジューラーの窓口が 3 つめで、向きが 1 つだけ逆である。 これまでの合流点は「外で決まった
値を取り込んでフローを起こす」向きだったが、要求ごとにハンドラーを別フローで走らせる形は
外の事象が新しいフローを始める。したがってフローは Future ではなく応答の口を持つ —
待っているのが他のフローではなく接続だからである。
塞ぐ地点は 1 つでなければならない。 進行中の要求と待ち受けを別々に待つと、片方で待って
いる間にもう片方が進めない。自分のサーバーを自分で叩くプログラム(ループバック)はそれで
必ず止まるので、外の口はすべて「走れるフローが尽きた地点」でまとめて 1 巡見る。端末の入力
待ちも、外の口が開いている間は長く塞がないように区切る。
オラクルは実行文脈を増やし、この実装は増やさない。 向こうは待ち受けを goroutine で回して
channel でスケジューラーへ渡すが、こちらはスケジューラーを 1 本で回すので、接続も要求も非閉塞の
口を 1 巡ずつ進める状態機械にしてある。呼び手から見える事実は変わらない — 要求ごとに別の
フローが走り、ハンドラーが待つ間他の要求が進む。
接続は 1 要求で閉じる。 応答に Connection: close を付けるので、穏当停止の drain は
「開いている接続を待つ」だけで済む — 遊休の接続を数え分ける必要が無い。
送出の開始は応答の完成ではない。 stream を持つ応答はハンドラーの完了で状態行を送り、
生産者が別のフローとして本文を流す。送った状態行は取り消せないので、妥当性は入る前に
見る — 形が不正なら送出へ入らず 500 である。
待たずに発行した write が落ちないのは構造の帰結である。 オラクルは書き出しをホストの
実行文脈へ委ねるので、発行済みで未達のチャンクを数えてから口を閉じる必要がある。この実装は
同じスケジューラーが送出も進めるので、積んだ時点で応答に入ったことが確定する — 数える欄が
要らない。
ハンドラーは回収器のルートである。 待ち受けが掴む閉包は、要求が来るまでどこからも辿れない。
ルートに据えないと掃かれ、要求が来た地点で墓標に当たる(serve を呼んだフローを先に終わらせる
入力を組んで、ルートを外した実行が exit 71 になることを確かめてある)。進行中の要求と serve の
Future も同じ理由でルートである — 待たずに発行した要求は、解決する地点まで誰からも辿れない。
応答ヘッダーも待ち受けが足す。 Date と、書かれなかった Content-Type(中身からの推測)
はどちらもオラクルが足しており、呼び手のプログラムが読める値である。推測の規則は
WHATWG の MIME sniffing に沿った閉じたシグネチャの表なので、表ごと写した(crate::httpsniff)。
表は記憶から書かない — オラクルに 70 通りの入力を問うて期待値を採り、クレート試験が
それを見張る。実際、記憶で書いた 3 つ(image/x-icon の綴り・webp の mask の長さ・rar の
シグネチャ)はその試験が捕まえた。
送出の頭は最初のチャンクまで待つ。 種別を中身から推測する規則は「書かれた本文」が要る
ので、1 バイトも見ずに頭を送ると推測できない。1 バイトも書かれなければ chunked では
なく長さ 0 の応答になる(どちらもオラクルと同じ)。
本文を持てない状態には長さも種別も付けない(1xx / 204 / 304)。生の応答を 11 通り
(本文の種別 4・空・ハンドラーが書いた種別・204・404・chunked 2 種・HEAD 2 種)で両側から
採り、Date の値を除いて 1 バイトずつ一致することを確かめてある。
7.4 self-host した prelude の意味
prelude は生成物へ必ず入るので、面は第 1 段からある(§6)。欠けるのは意味の方で、
メソッドディスパッチ・範囲・添字は到達したところで止まる。段が進むごとに、止まった入口から順に
意味を与える。
7.5 多倍長整数 — 閉じた
Big を足した(rt/src/bignum.rs)。外部クレートは要らなかった — 要るのは符号と
基数 2^32 の絶対値、四則と比較と 10 進の入出力だけで、どれも標準ライブラリで書ける。
i64 に収まる値は多倍長で持たない。 オラクルの構築子は収まれば Int へ縮めるので、
こちらも同じに保つ — 同じ数を 2 通りに表さないことが、等価判定を種別の比較だけで済ませる
不変条件である。除算の丸めもゼロ方向に揃えてある(Quo / Rem であって Div / Mod
ではない)。
突き合わせは実コーパスのほか、境界(i64 の両端・その隣)と桁数を撒いた 1,240 行の
出力で行った。