std:http/server — HTTP サーバー
標準ライブラリモジュール (../index.md)。本書中の裸の §N は本書の節を指す。言語の意味論は ../../language-spec.md を参照。共通型 (Request / Response / Handler) は ../http.md、HTTP クライアントは client.md。
import server := "std:http/server" は serve 1 slot のみを持つ namespace object を server に束縛する。std:http/server を import すること自体が「このコードは接続を待ち受ける」という capability 宣言になる (../index.md の capability 可視化)。TLS 終端 (§12) の証明書読み込みもこの capability に含まれ、別途 std:fs を import する必要はない。
import { serve } := "std:http/server" handler := { req | { status := 200, body := "hello, hikari!" |} } print "listening on http://localhost:8080" serve({ port := 8080 |}, handler)!
serve は Net を持ち(../../language-spec.md §17.9)、要求ごとに呼ぶ Handler の効果を呼び出し元へ透過させる(EffectConduit)— ファイルを読むハンドラーを渡せば serve の呼びは Net と Fs を持つ。
1. serve(config, handler)
- 引数:
(config, handler)2-tuple - 第 1 引数は 設定レコード:
port必須 + タイムアウト・停止シグナル・TLS 等の任意スロット (§9)。閉じた・本体なしのレコードであること。ポートのみで待ち受ける最簡形もserve({ port := 8080 |}, handler)とレコードで書く (Int の短縮形は持たない — 設定の入り口を 1 つに揃える) - 第 1 引数が設定レコードでない (レコード以外・本体付き)・
portが欠落 / 非 Int / 範囲外・任意スロットの型違反等は、呼び出し位置でError(panic 型, language-spec.md §16)。設定レコードの詳細な検証規則は §9 handler: 必須スロット 1 個の callable ({req | ...}形)。非 callable・必須スロット数が 1 個でない場合は呼び出し位置でError。設定レコードがstateを持つときだけ必須スロット 2 個 ({st, req | ...}形) になる (§13)- バインド先は全インターフェース (バインドアドレスを選ぶ手段は持たない。§7)
- 戻り値:
Future。!(=()) で resolve 値を待つと、サーバーが終了するまでブロックする (prelude.md §9.4 のブロッキング I/O 規則に乗る)。root リテラル本体がserve(...)!で待っている限り、root の評価は完了しない - resolve 値は
Result(State)。Stateは設定レコードのstateが運ぶ状態の型で、stateを持たない設定ではUnitである (§13): - 失敗時:
Err(e)—eは回復可能エラー{kind, message}。kindはlisten_error(bind 失敗・TLS 証明書読み込み失敗) /io_error(稼働中の致命的失敗) の 2 種のみ (§4) - 成功時:
Ok(s)は graceful shutdown が完了したときに発生する (§10)。sは最後のハンドラーが返した状態で、stateを持たない設定では()になる。shutdownを設定しない限り従来どおり成功値には到達しない - 停止手段は設定レコードの
shutdown(§10) で提供する。shutdownを設定しなければサーバーの停止はプロセス終了 (Ctrl-C 等) に委ねる
2. Request レコード (ハンドラーが受け取るもの)
handler に渡される req はスロット {method, path, query, headers, body, body_bytes} を持つ closed・immutable なレコード。実装が接続ごとに構築する。この形は std:http の型メンバー Request (../http.md §2) を満たす:
| スロット | 型 | 意味 |
|---|---|---|
method |
String | "GET" / "POST" 等 (受信した表記のまま。大文字化などの正規化はしない) |
path |
String | クエリを除いたパス (例 /todos) |
query |
String | 生のクエリ文字列 (例 a=1&b=2。無ければ空 String)。パースはユーザー側の責務 |
headers |
InsertionMap(String, String) |
ヘッダー名 (String) → 値 (String)。正規化・同名連結の規則は client.md §2 と共通 |
body |
String | 本文を UTF-8 String として読んだもの |
body_bytes |
Bytes | 本文の生バイト列 |
3. Response レコード (ハンドラーが返すもの)
handler は req を受け取り、スロットを持つレコードを返す (状態を持つサーバーでは (Response, State) の 2-tuple を返す。§13)。この形は std:http の型メンバー Response (../http.md §3) を満たす必要がある:
| スロット | 必須 | 型 | 意味 |
|---|---|---|---|
status |
必須 | Int | HTTP ステータスコード (100〜599) |
headers |
任意 | InsertionMap(String, String) |
ヘッダー名 → 値 (client.md §2 と共通の規則)。省略時はヘッダーなし |
body |
任意 | String / Bytes | 応答本文 (一括返却)。省略時は空 |
body_bytes |
任意 | Bytes | 応答本文の生バイト列 (一括返却) |
stream |
任意 | callable { sink | ... } |
ストリーミング (逐次) 応答の生産者 (§11) |
- 本文の決め方:
streamがあればストリーミングモードに入り、body/body_bytesは無視する (§11)streamが無ければ一括返却。bodyとbody_bytesが両方あればbody_bytesを優先するContent-Length等の実装が付与すべきヘッダーはhandlerが指定しなくても実装側が補う (ストリーミング時はContent-Lengthを付けず chunked transfer にする。§11)- 余分なスロットは無視する
- ヘッダー名と値の CR (0x0D) / LF (0x0A) は線へ出す前に除去する。 ヘッダーの区切りは CR LF なので、値にそれを含めたまま書けると、ヘッダー 1 つの値がヘッダーの列そのものになる — 要求の綴りを応答ヘッダーへ写したハンドラーは、任意のヘッダーと任意の本文を注入される (応答分割)。CR / LF はヘッダーの名前にも値にも正当に現れないので、除去して失うものは無い。符号化ではなく除去である (符号化すると出る文字そのものが変わり、名前をそのまま読むクライアントと噛み合わなくなる)。応答を 500 にはしない — 落として失うものが無いところに分岐を増やさない。同じ規則を要求の組み立て側も持つ (client.md §2)。
status を持つ限り headers / body / body_bytes / stream を省略できるため、std:http/client の応答レコード (status に加え headers / body / body_bytes を常に持つ。client.md §1) は幅サブタイプとしてそのまま Response を満たす。これによりプロキシ的なハンドラーが 1 行で書ける:
import { serve } := "std:http/server" import client := "std:http/client" serve({ port := 8080 |}, { req | client.get("https://example.com" + req.path)!.unwrap! })!
ハンドラーの評価が panic した場合、または戻り値が Response の形を満たさない場合 (status 欠落・非 Int 等)、その接続には 500 (空本文) を返し、サーバーは稼働を継続する (1 接続の失敗でサーバー全体を落とさない)。この失敗は serve の Err にはしない (§4)。ストリーミングでは status 送出後の panic は 500 に差し替えられない — 扱いは §11。
4. エラーモデル (serve の Err)
serve の resolve 値が運ぶ Err(e) の kind は閉じた一覧:
| kind | 意味 |
|---|---|
listen_error |
bind 失敗 (ポート使用中・権限不足等)、および TLS 証明書/鍵の読み込み失敗 (§12) |
io_error |
稼働中の致命的 I/O 失敗 (accept を継続できない等) |
- ハンドラー由来の失敗 (panic・不正な応答レコード) は
Errにしない — §3 のとおり500を返し稼働を継続する - 要求の綴りが壊れているときも
Errにしない — その接続に400(空本文) を返して閉じ、サーバーは稼働を継続する。壊れているとは次のいずれかである: - 開始行・ヘッダー行が読めない
Content-Lengthが読めない (数でない・負・64bit に収まらない・同名で値が食い違う)Transfer-Encoding: chunkedの本文の綴りが壊れている
読めない Content-Length を「本文なし」と読み替えてはならない。 本文を付けたつもりの要求が本文の無い要求としてハンドラーへ渡ると、ハンドラーは嘘の要求に答えることになる。壊れた chunked を断るのと同じ扱いにする
- ヘッダーが
max_header_bytes(§9) を超えたら431(空本文) を返して閉じる - 引数の型ミス (
port/handler/ 設定レコードの各スロット) はErrではなく呼び出し位置のError(§1・§9。std:http/clientの引数型ミスと同じ区分け) - ストリーミング中の書き込み失敗 (クライアント切断等) は
sink.write(chunk)!が運ぶErrで表れ (§11)、serveのErrにはしない — サーバーは稼働を継続する - graceful shutdown の完了は
ErrではなくOk(s)—sは最後の状態で、stateを持たない設定では()(§1・§10・§13)
match serve({ port := 8080 |}, handler)! { Ok(_) => () # shutdown を設定しなければ到達しない (§1・§10) Err(e) => print e.message }
5. 並行モデル
リクエストごとに handler の評価を別フローで開始する (fork と同じ機構、prelude.md §9.1)。handler の中で ! (await) すると、その待機中に他のリクエストの処理が scheduler 上で進む — 既存の協調スケジューラーの意味論をそのまま使い、追加の並行規則は設けない。ストリーミング応答の sink.write(chunk)! も同じく中断点になり、flush 中に他リクエストが進む (§11)。
状態を持つサーバー (§13) ではハンドラーが交錯しない。 状態はちょうど 1 つのハンドラーが所有し、次のハンドラーは前のハンドラーが状態を返してから開始する。ハンドラーの中に中断点があっても、その待機中に別のリクエストのハンドラーが同じ状態を見ることはない。所有が 1 つであることの帰結であり、追加の錠前ではない。state を持たない設定の並行性は従来どおり変わらない。
6. root 完了時の扱い
serve(...)! を root リテラル本体が待っている限り root の評価は完了しないため、通常は放棄が起きない。serve の呼び出し元が ! を経ずに Future を放置したまま root 本体の評価が完了した場合、この Future は language-spec.md §13.3 の「未解決 Future の放棄」規則に従う。実装は listener を閉じる責任を負う。これは shutdown 設定 (§10) による穏当停止とは別経路であり、放棄時の resolve 値は Ok(s) ではない (graceful shutdown 完了を意味しないため)。
7. 持たないもの
以下は持たない。頭に「委ねる」と書いたものは、この層に入れないと決めたものである。残りは将来枠。
- (委ねる) ルーティング・middleware 構造・静的ファイル配信 — pkg: エコシステムに委ねる。
Handler型 (../http.md §4) がその契約点になる - 接続の使い回し (keep-alive) — 応答には
Connection: closeを付け、書き終えたら閉じる。接続ごとにちょうど 1 要求なので、穏当停止 (§10) の drain は「開いている接続を待つ」だけで済み、待機中の遊休接続を数える必要が無い。遊休接続そのものが無いので、アイドル上限の設定 (§9) も持たない - HTTP/2・WebSocket
- リクエスト本文のストリーミング読み (受信側。応答側のストリーミングは §11 で提供)
- mTLS (クライアント証明書検証)・TLS 最小バージョン / 暗号スイートの明示設定 (TLS は
cert_file/key_fileによる終端のみ。§12) - バインドアドレスの選択 (全インターフェース固定)
- 状態の細かい引き渡し —
state(§13) を使うサーバーでは、ハンドラーが状態を返すまで次のハンドラーが始まらない (§5)。中断点の手前で状態を手放して並行性を取り戻す口は持たない。状態を持つハンドラーの中で待つと、その待機がサーバー全体の待機になる点に注意する。待ちを伴う仕事は状態を返した後のstream(§11・状態は捕獲できない) か、状態を持たない別のサーバーへ寄せる - 状態を持つサーバーでのストリーミングからの状態更新 —
streamの生産者は応答を返した後に走るので状態を捕獲できない (§13)。生産者から状態を書き換える口は持たない
8. 例: match によるルーティング
ルーティングは組込のルーターを持たず、handler の中で match を使って書く。match のタプルリテラルパターンは型照合であり (prelude.md §8.4)、("GET", "/") のような値の組を要素ごとの == で照合するパターンは持たないため、method → path の入れ子 match で書く。ヘッダーは std:map の InsertionMap で組み立てる (client.md §2):
import { serve } := "std:http/server" import json := "std:json" import m := "std:map" todos := ["milk", "bread"] handler := { req | match req.method { "GET" => match req.path { "/" => { status := 200, body := "hello, hikari!" |} "/todos" => hdrs := m.insertion.of([("Content-Type", "application/json")]) { status := 200, headers := hdrs, body := json.stringify(todos, 0).unwrap! |} _ => { status := 404, body := "not found" |} } "POST" => match req.path { "/todos" => { status := 201, body := "added: ${req.body}" |} _ => { status := 404, body := "not found" |} } _ => { status := 404, body := "not found" |} } } print "listening on http://localhost:8080" serve({ port := 8080 |}, handler)!
9. サーバー設定 (設定レコード)
serve の第 1 引数の 設定レコードは、ポート (port) に加えてタイムアウト等のサーバー単位の設定を運ぶ。port だけを持つ最簡形 serve({ port := 8080 |}, handler) から、任意スロットを足していくだけで設定を拡張できる (第 1 引数は常にこのレコード 1 種 — §1)。
| スロット | 必須 | 型 | 意味 | 省略時 |
|---|---|---|---|---|
port |
必須 | Int (1〜65535) |
バインドするポート | — |
state |
任意 | 任意 (制限は §13) | ハンドラーへ引き渡す初期状態 (§13) | なし (ハンドラーは 1 スロット) |
read_timeout |
任意 | Int (ms, ≥0) |
リクエスト全体の読み取り時間上限 | 0 (無制限) |
write_timeout |
任意 | Int (ms, ≥0) |
応答書き出しの時間上限 | 0 (無制限) |
read_header_timeout |
任意 | Int (ms, ≥0) |
ヘッダー読み取りの時間上限 | 0 (無制限) |
max_header_bytes |
任意 | Int (bytes, ≥0) |
リクエストヘッダーの最大バイト数 | 実装既定 |
shutdown |
任意 | Future |
穏当停止のシグナル (§10) | なし (停止手段なし) |
shutdown_timeout |
任意 | Int (ms, ≥0) |
drain の時間上限 (§10) | 0 (drain 完了まで待つ) |
tls |
任意 | { cert_file: String, key_file: String |} |
TLS 終端 (§12) | 平文 HTTP |
- 時間値の単位は integer ミリ秒で、prelude の
sleep(ms)/Future.timeout(ms)と揃える (std:time への依存を持ち込まない)。0は「上限なし」を表す - 各任意スロットが存在するのに型・範囲が不正 (非 Int、負値、
tlsが不正な形 等) な場合は、呼び出し位置でError— bind 前に構造的に判定できる不整合はErrに運ばず即時に落とす (§4) - 未知の余分なスロットは無視する — Hikari の record は幅サブタイプ (language-spec.md §17.4) であり、Response (§3) が余分なスロットを無視するのと一貫させる。設定名の綴りエラーは静的には検出されない点に注意
- 応答をストリーミング (§11) する場合、
write_timeoutは応答全体 (全チャンクの送出完了まで) にかかる。長時間ストリーミングするサーバーはwrite_timeout := 0にするか十分大きな値にする
import { serve } := "std:http/server" serve({ port := 8080, read_timeout := 5000, write_timeout := 5000, max_header_bytes := 1048576 |}, handler)!
10. graceful shutdown (shutdown)
設定レコードの shutdown に Future を渡すと、その Future が解決した時点を停止シグナルとしてサーバーを穏当に終了させる。停止シグナルは fork / race / wait_any (prelude.md §9) など通常の Future の合成でユーザーが組み立てる — サーバーは専用の停止プリミティブを持たず、既存の並行機構との合成でこの口を実現する。
shutdownのFutureが解決した時点で (解決値の中身は問わない。Ok/Err/ panic のいずれでも「解決した」ことだけを停止合図とみなす)、サーバーは新規接続の受付を止め、処理中 (in-flight) のハンドラーを drain する- drain 完了で
serveのFutureはOk(s)に解決する (§1 で予約されていた成功値)。sは最後のハンドラーが返した状態で、stateを持たない設定では()である (§13) shutdown_timeout(ms) を与えると drain の待ち時間を上限で打ち切り、残る接続を強制 close する。超過して強制 close した場合もserveはOk(s)に解決する (停止はユーザー意図どおり完了したため)。shutdown_timeout := 0(既定) は「drain 完了まで待つ」shutdownのFutureが永久に解決しなければ、サーバーは従来どおり停止せず走り続ける (プロセス終了に委ねる)- graceful shutdown による停止は、root 完了時の放棄 (§6・language-spec.md §13.3) や
Future.cancel!(prelude.md §9.8) による停止とは別経路である。放棄・キャンセルで止まった場合の resolve 値はOk(s)ではない (穏当停止の完了を意味しないため)。状態を持つサーバーをこの経路で止めると最後の状態は返らない
import { serve } := "std:http/server" # 実際はシグナル待ちの Future 等。ここでは 10 秒後に停止する例 stop := fork { sleep 10000 } match serve({ port := 8080, shutdown := stop, shutdown_timeout := 3000 |}, handler)! { Ok(_) => print "stopped gracefully" Err(e) => print e.message }
11. ストリーミング / チャンク応答 (stream)
応答レコードの stream に生産者 (必須スロット 1 個の callable { sink | ... }) を置くと、応答本文を一括ではなく逐次 (チャンク) で送出できる。stream があるとき body / body_bytes は無視される (§3)。
- serve はまず
statusとheadersを送出する。このときContent-Lengthは付けず chunked transfer encoding にする - 続いて生産者を当該リクエストのフローとして評価し、引数に
sink(実装が渡す不透明ハンドル) を渡す sinkは 1 つのメソッドwriteを持つ:
sink.write(chunk)! # chunk: String / Bytes。Future(Result(Unit)) を返す
chunkを 1 チャンクとしてクライアントへ flush する。chunkが String / Bytes でなければ呼び出し位置でError- 返り値は
Future(Result(Unit))。!で待つと、flush 成功でOk(())、書き込み失敗 (クライアント切断等) でErr({kind, message})に解決する。ブロッキング I/O 規則 (prelude.md §9.4) に乗り、flush 中は他フローが進む (§5) - ハンドラーは
Errを観測したら送出を止められる (書き続けても無意味なため)。このErrはserve全体のErrにはしない (§4) — サーバーは稼働を継続する
import { serve } := "std:http/server" handler := { req | { status := 200, stream := { sink | sink.write("first chunk\n")!.unwrap! sleep 100 sink.write("second chunk\n")!.unwrap! } |} } serve({ port := 8080 |}, handler)!
12. TLS 終端 (tls)
設定レコードの tls に { cert_file: String, key_file: String |} (PEM ファイルのパス) を渡すと、平文 HTTP ではなく TLS で終端する。
| スロット | 必須 | 型 | 意味 |
|---|---|---|---|
cert_file |
必須 | String | サーバー証明書 (PEM) のファイルパス |
key_file |
必須 | String | 秘密鍵 (PEM) のファイルパス |
tlsを与えたときはcert_file/key_fileの両方が必須。欠落・非 String は呼び出し位置でError(§9)- 証明書 / キーの読み込み失敗・bind 失敗は
Err(listen_error)(§4) - 証明書ファイルの読み込みは
serve(= 待ち受け capability) の動作の一部であり、別途std:fsを import する必要はない (冒頭の capability 記述) - TLS はサーバー証明書による終端のみ。mTLS (クライアント証明書検証)・最小バージョン / 暗号スイートの明示設定は §7 のとおり持たない
import { serve } := "std:http/server" serve({ port := 8443, tls := { cert_file := "server.crt", key_file := "server.key" |} |}, handler)!
13. 状態を持つサーバー (state)
設定レコードの state に初期状態を渡すと、serve はその状態をハンドラーの引数として引き渡し、ハンドラーが返した次の状態を次のリクエストへ回す。要求列に対する畳み込み (fold、prelude.md §6) と同じ形である。
stateを持つ設定では、handlerは必須スロット 2 個の callable{ st, req | ... }になる。第 1 引数が状態・第 2 引数が Request で、順序はfoldの{ acc, x | ... }に揃える- ハンドラーは
(Response, State)の 2-tuple を返す。第 1 要素が応答レコード (§3)、第 2 要素が次の状態である - 状態は受け付け順に 1 つずつ引き渡される。ハンドラーが交錯しないことは §5 に定める
serveの resolve 値はResult(State)で、graceful shutdown (§10) が完了したとき最後の状態がOk(s)として返る (§1)stateを持たない設定では従来どおりhandlerは 1 スロット、戻りは応答レコードのみ、resolve 値のStateはUnitである。stateの有無で決まるのはこの 3 つで、他の設定スロットの意味は変わらないhandlerの必須スロット数がstateの有無と食い違う場合は、呼び出し位置でError(§1・§4 の区分けに従う。bind の前に構造的に判定できる)- 戻りが 2-tuple でない場合は接続ごとの失敗として扱う — その接続には
500(空本文) を返し、状態は更新せず前の状態のまま次のリクエストへ回す (§3 の「戻り値がResponseの形を満たさない場合」と同じ区分け)。ハンドラーの評価が panic した場合も同じで、状態は前のまま残る。1 接続の失敗で状態を失わない
状態を引数ではなく設定のスロットに置くのは所有のためである。 スロットへ置く形は record リテラルへ入れる形なので、既存の「コンテナーへ入れる=消費」規則 (static-analysis.md §2.14) がそのまま所有の移動を記録する。引数で受ける形にすると宣言型が型変数になり、型変数の位置には規律を課す先が無い (language-spec.md §17.8 の「将来枠」) ので所有が移らない。型の書きやすさより所有を採った — 設定の中身に依存する型は 1 つのシグネチャでは書けないので、その分の手当てを処理系が持つ。
import { Request } := "std:http" import { serve } := "std:http/server" # 状態は「これまでに受けた要求の数」。 handler := { st: Int, req: Request | n := st + 1 { status := 200, body := "count=${n}\n" |}, n } match serve({ port := 8080, state := 0, shutdown := stop |}, handler)! { Ok(n) => print "served ${n} requests" Err(e) => print e.message }
13.1 状態に多重度型を持たせるとき
状態が多重度型の値を含む (language-spec.md §17.8) なら、状態そのものが追跡対象でなければならない — 多重度型そのものか、多重度を伝播する包み (enum の variant payload・タプル・Future。static-analysis.md §2.14) である。満たさない場合は静的検査が報告する (static-analysis.md §2.14 の state-not-owned)。
理由は所有が 2 つになることである。多重度型でない状態を state へ置いても、そこで消費として記録されない (多重度を伝播しない構造に入れた値の追跡は、そこで終わる — static-analysis.md §2.14)。すると呼び手の手元に同じ表が残り、そこから抜き出した資源と serve の側の資源が同じ実体を指す。二重の消費が静的にも実行時にも捕まらない。
状態を ExactlyOnce / AtMostOnce で包むと、state へ置いた時点が消費になり、以降その名前へ触れると use-after-consume になる。所有が serve へ移ったことが型で表れる。
import { Request } := "std:http" import { serve } := "std:http/server" # Lease は ExactlyOnce。表そのものも多重度型で包む。 type Table := ExactlyOnce({ held: {| List(Lease) } |}) handler := { st: Table, req: Request | got, rest := take(st, req.body) match got { Some(l) => { status := 200, body := l.done "ok" |}, rest None => { status := 404, body := "no such lease\n" |}, rest } } serve({ port := 8080, state := empty_table!, shutdown := stop |}, handler)!
1 リクエストの中の規律はそのまま働く。 抜き出した資源はハンドラーの中でローカルな追跡対象なので、決着し忘れれば never-consumed、二重に決着させれば use-after-consume になる。捕獲が 1 つも無いため affine-not-copyable は起こらない。
14. ブラウザー (wasm) での実行
wasm ビルドでは、Playground (web/play.html) が実行対象ソースの std:http/server の import を自動判定し、サーバーペインで実行する (ペインの定めは ../../playground.md)。この実行では待ち受けるのも要求を捌くのも同じ実装で、ソケットの代わりにページが積んだ生の HTTP のバイト列を受ける。要求のパースも応答の組み立ても同じ経路を通るため、Request レコードの形 (§2)・応答ヘッダーの補完と Content-Length (§3)・チャンク送出 (§11)・応答の値の形が不正なときの 500 (§3)・使用中の port への Err(listen_error) (§4) は、いずれもホストで走らせたときと同じ答えになる。
同じプログラムが std:http/client で自分の待ち受けへ出した要求も、この経路を通って届く (client.md §11)。
ブラウザーで再現しないのは次の 2 つだけで、本書が定める他の挙動は実機と同じである。
- TLS 終端 (§12) — ブラウザーは TLS の実装を持たない。
tlsを渡したserveは呼び出しで止まる。持っていない能力を「送れなかった」の顔をしたErrに化けさせない規律 (§4) に従う - ハンドラーの
panicによる 500 (§3) — ホストではpanicの巻き戻しをフロー単位で捕まえて 500 へまとめ、稼働を続ける。wasm32-unknown-unknown は安定版で巻き戻せない (panic=abort。../../../internals/wasm-host.md) ので、ブラウザーでは捕まえる代わりに呼び出し全体が trap する。応答の値の形が不正なときの 500 (status を欠いた record・callable) は panic を伴わない値の検査だけで判定するので、そちらは両環境で同じ答えになる — 再現しないのは panic 経由の分だけである。この差はexamples/http_panic_test.hikaがゲートにしている
read_timeout (§9) はブラウザーでも発火する。刻むのはホストと同じ実時計ではなく、足止めのたびに進む番号 (hostclock::Mono。../../../internals/wasm-host.md) である。
それでも期限が来るのは、サーバーのセッション中はこの番号が実時間とほぼ 1:1 で進むためである — 待ち受けに他へ進む仕事が無い間、スケジューラーはこの番号を進め、サーバーのセッション中に限りそのぶん実時間でも足止めする。したがって read_timeout の期限は、ホストで走らせたときに近い時間で来る。ホストと違うのは、計算に費やした実時間が番号に載らないことだけである (番号が進むのは足止めのときだけなので)。