Skip to content

http on --wasip2 (all 6 methods + headers)#25

Merged
jasisz merged 13 commits into
mainfrom
0.19-http-wasip2
May 12, 2026
Merged

http on --wasip2 (all 6 methods + headers)#25
jasisz merged 13 commits into
mainfrom
0.19-http-wasip2

Conversation

@jasisz
Copy link
Copy Markdown
Owner

@jasisz jasisz commented May 9, 2026

HTTP support for --target wasip2 — direct WIT lowering to wasi:http (no preview1 adapter), all 6 methods, real response headers with multi-value preservation, per-error-code names.

Steps

Step Scope Status
1 Cargo dep wasmtime-wasi-http, host linker (add_only_http_to_linker_sync), WasiHttpView impl
A 16 wasi:http/* slot variants in Wasip2ImportSlot
B+C Register Http.get slots + WIT world conditional import wasi:http/outgoing-handler@0.2.4
D __rt_http_get helper — URL parse, outgoing-request build, handle/subscribe/poll/future.get pipeline (~700 LoC)
E Wire helper into call site, graduate Http.get on wasip2, e2e test
F+G Drop incoming-body on error paths + surface real response headers via incoming-response.headers + fields.entries
H Multi-value headers via Map.get + reverse-iterate-prepend (RFC 6265 Set-Cookie order)
I Per-error-code variant names from error-code discriminant (39 cases: connection-refused, DNS-timeout, …)
J Http.head + Http.delete via shared helper + set-method slot
K Http.post + Http.put + Http.patch with body marshalling (request.body/body.write/blocking-write/finish) + user headers iteration + Content-Type

Test coverage (tests/wasip2_http.rs)

  • http_get_returns_status_and_body — status=200 + body length + Content-type lookup
  • http_get_preserves_multi_value_header_order — three Set-Cookie + two X-Order headers, asserts emit-order preserved
  • http_get_surfaces_connection_refused — bind+drop port, asserts Err message contains connection-refused / connection-failed
  • http_head_and_delete_dispatch_correct_method — server tags responses with X-Method; asserts each verb dispatched correctly + HEAD has empty body
  • http_post_put_patch_round_trip_body_and_headers — body bytes + Content-Type + user-provided X-Custom round-trip through all three body-bearing methods

Test plan

  • cargo build --features wasip2 --bin aver clean
  • tests/wasip2_poc.rs 3/3
  • tests/wasip2_stress.rs 6/6
  • tests/wasip2_http.rs 5/5
  • Lib unit tests 617/617
  • cargo fmt --check clean
  • CI green on 0.19-http-wasip2

Out of scope (deferred)

  • HTTPS — set-scheme(HTTPS) works, but TLS depends on host wasmtime-wasi-http build; not exercised
  • --wasip2 --record integration — recording rejected at CLI today
  • URL parser edge cases — userinfo, IPv6 brackets, port-only authority
  • Content-Length explicit setting — wasmtime defaults to Transfer-Encoding: chunked when unset; works against any modern server but requires the test server to decode chunked
  • Payload extraction from error-code variants (DNS-error rcode, internal-error message, …) — only variant name surfaces today

Add `wasmtime-wasi-http = 44.0.1` as a `wasip2`-feature dep, attach
`WasiHttpCtx` + `WasiHttpView` impl to the existing CLI host, and
register `wasi:http/*` via `add_only_http_to_linker_sync` (the full
`add_to_linker_sync` re-registers wasi:io / wasi:cli / wasi:clocks
that `wasmtime_wasi::p2::add_to_linker_sync` already wired and trips
"map entry wasi:io/error@0.2.x defined twice").

Components that don't import wasi:http see no behaviour change —
the dispatcher is dormant unless the guest calls into it. Setup
needed before the wasm-gc backend can lower `Http.get` to
`wasi:http/outgoing-handler.handle` in step 2.

baseline regressions:
- `aver run --wasip2` on a Console-only program still prints
- `tests/wasip2_poc.rs` 3/3 ✅
- `tests/wasip2_stress.rs` 6/6 ✅

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages
Copy link
Copy Markdown
Contributor

cloudflare-workers-and-pages Bot commented May 9, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
aver 14268cf Commit Preview URL May 12 2026, 07:28 PM

jasisz and others added 9 commits May 9, 2026 18:53
Declare every canonical-ABI import the upcoming `__rt_http_get`
helper will need, with full doc-comments documenting each slot's
WIT signature, canonical-ABI lowering shape, retptr layout where
applicable, and the WASI 0.2 ceremony each represents:

- 12 method/constructor slots: fields/outgoing-request constructors,
  outgoing-request setters (scheme, authority, path-with-query),
  outgoing-handler.handle, future-incoming-response.subscribe/get,
  incoming-response.status/consume, incoming-body.stream/finish.
- 4 resource-drop slots: outgoing-request,
  future-incoming-response, incoming-response, future-trailers.

Implementations populate `module_field_pair`, `params`, `results`
matching `wit_component::ComponentEncoder`'s name resolution + the
canonical-ABI flat-vs-retptr discipline. No callers yet (Step D
will add `__rt_http_get`); rustc emits a dead_code warning for the
new variants, accepted until callers land.

The slot enum already has 27 wasi:cli / wasi:io / wasi:filesystem /
wasi:clocks / wasi:random variants from 0.18; adding wasi:http here
keeps the registry shape uniform — no parallel http registry, no
sub-enum nesting. WASI 0.3 will get a sibling `Wasip3ImportSlot`
when the time comes; the same pattern repeats with ~3 slots
instead of 12 (native future<T> / stream<u8> collapse the
choreography).

baseline checks:
- `cargo build --features wasip2 --bin aver` ✓ (one expected
  dead_code warning on the new variants)
- `tests/wasip2_poc.rs` 3/3 ✓
- `tests/wasip2_stress.rs` 6/6 ✓

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B (registry tracking, src/codegen/wasm_gc/{effects,module}.rs):
EffectName::HttpGet now returns true from `lowers_on_wasip2`, and
the per-effect dispatch in module.rs adds an arm registering the
12 new HttpTypes/HttpOutgoingHandler slots plus 4 reused ones
(IoPollPoll / IoPollResourceDropPollable / InputStreamBlockingRead
/ IoStreamsResourceDropInputStream — all already wired by Time.sleep
and Disk.readText). 16-slot footprint per Http.get is just how
WASI 0.2 spells "outgoing HTTP request"; the comment in module.rs
points at WASI 0.3 which collapses this to ~3 imports.

The registration is dormant in this commit: `effect_check.rs`
still rejects every `Http.*` upstream, so no real program reaches
the wasm-gc emitter yet. Step E flips effect_check.

C (WIT world, src/codegen/wasip2/{wrap,wit}.rs):
`compile_to_component` now scans the core wasm's import section
and detects whether any module name starts with `wasi:http/`.
That bit threads through `emit_world_wit(world, needs_http)` which
appends `import wasi:http/outgoing-handler@0.2.4;` to the
`wasi:cli/command`-include line when needed. Source of truth is
the actual core imports — no second flag to keep in sync between
codegen and wrap.

Empty programs and non-Http programs emit identical WIT to before
(needs_http=false → unchanged body). Future Wasip3World variant
will branch on the same shape, just with a different include and
fewer imports per effect.

baseline checks:
- `cargo build --features wasip2 --bin aver` ✓
- `tests/wasip2_poc.rs` 3/3 ✓
- `tests/wasip2_stress.rs` 6/6 ✓

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`emit_http_get` lowers the full wasi:http/outgoing-handler.handle
pipeline to a single wasm-encoder Function: URL parse (scheme +
authority + path-with-query), fields constructor, outgoing-request
constructor + setters, handle (with Err short-circuit), pollable
subscribe + poll + drop, future.get with three-layer discriminant
checks (option-Some / middle-Ok / inner-Ok at offsets 0, 8, 16
respectively — error-code's `option<u64>` payload forces align=8
through every wrapping layer), status, consume, body.stream, drain
loop, per-call resource drops, and HttpResponse build wrapped in
Result.Ok.

v1 PoC scope: plain http://, empty response headers (incoming-
response.headers slot not registered yet), minimal URL parser (no
userinfo / no IPv6 brackets / no port-only authority).

Folds the cargo fmt drift from `a4f23b14` into the same commit so
the next CI run clears the Format gate.
…wasip2

Allocates `HttpGetIndices` after `disk_list_dir` in `module.rs`
(gated on all 16 new wasi:http slots + 4 reused + String / Result
/ HttpResponse / Map<String, List<String>> type slots), threads
the helper bundle through `funcs.function` + `Wasip2Lowering.
http_get_fn_idx` + `codes.function`, and adds `emit_http_get_wasip2`
to `body/builtins_wasip2.rs` for the call-site lowering.

`effect_check.rs` graduates `Http.get` to None (lowered via direct
WIT) and bumps the rest of `Http.*` to "Phase 2.1 / 0.19" so the
diagnostic stays helpful for the still-rejected verbs.

End-to-end test in `tests/wasip2_http.rs` spins up a Python http.
server bound to OS-assigned port (parsed from stdout to avoid fixed-
port flakes), compiles + runs an Aver program calling Http.get,
asserts status=200 and body length matches the served fixture.
…eaders

Step F — eliminate the incoming-body resource leak on error paths
between consume() and finish(). Adds [resource-drop]incoming-body
slot; wires drop_incoming_body_fn through HttpGetHelperFns; calls
it on body.stream Err and blocking-read non-closed Err in
child→parent order (body before response).

Step G — replace the empty-headers placeholder with the real
response.headers map. Adds three slots:
- [method]incoming-response.headers (-> own<fields>)
- [method]fields.entries (-> list<tuple<field-key, field-value>>)
- [resource-drop]fields (child of incoming-response, must drop
  before drop_incoming_response per WIT spec)

Wires from_lm_fn (bridge helper) and map_set_fn (Map<String,
List<String>> helper) through HttpGetHelperFns. The body emit
loops over the entries list, memory.copy's each (name, value)
pair into LM[0..len], lifts via __rt_string_from_lm, builds a
singleton List<String> per value, and folds into the headers map
via Map.set.

Multi-valued headers (same field-key in multiple entries — e.g.
Set-Cookie) currently overwrite via Map.set rather than appending
to the existing list. Surfacing the full multi-set requires
Map.get + List.prepend per entry; deferred since real HTTP
responses rarely repeat header names.

Test extended to assert headers_len > 0 and content-type lookup
returns "text/html" (Python http.server normalises field names
to lowercase per HTTP/1.1 case-insensitivity).
Step F+G's first cut overwrote duplicate field-keys via plain
Map.set, losing all values past the first for headers like
Set-Cookie that legitimately appear multiple times in a single
response. RFC 6265 requires preserving server-emit order for
Set-Cookie specifically; HTTP/1.1 §3.2.2 allows duplicate field
names with order-significant semantics for non-comma-combinable
headers.

Restructures the entry loop to:
- iterate REVERSE (idx = entries_len-1..=0 via signed compare)
- per entry, probe `Map.get(map, name)` → Option<List<String>>
- build new list = struct.new $list_string (val, tail) where
  tail = Some(prev) ⇒ prev, None ⇒ ref.null
- Map.set(map, name, new_list)

Reverse iteration + prepend yields forward order in the final
list without needing List.append (which would be O(n²) over the
loop). E.g. server emits Set-Cookie: a, then Set-Cookie: b →
processing b first builds [b], processing a next builds [a, b].

Wires Option<List<String>> type idx and Map.get fn idx through
HttpGetIndices/HttpGetHelperFns; the type was already auto-
registered by the Map<K,V> discovery walk.

New test http_get_preserves_multi_value_header_order spawns a
custom Python BaseHTTPRequestHandler that emits three Set-Cookie
+ two X-Order headers in known order and asserts the Aver-side
list comma-joins to the exact server-emitted sequence. Failure
mode regresses to overwrite-wins, missing-list, or reversed
order — any of those would change the asserted string.
Replace the generic "http: response error" with a per-discriminant
dispatch that surfaces the wasi:http error-code variant name
(connection-refused, DNS-timeout, TLS-protocol-error, …) verbatim.
v1: variant name only — payload extraction (DNS-error's rcode,
TLS-alert-received's alert-message, internal-error's option
<string>, etc.) deferred since most ops debugging needs the
variant tag, not the payload detail.

The host writes the error-code discriminant as a u8 at retptr+24
(error-code is the inner result's Err payload; payload starts at
+24, error-code's variant discriminant is the first byte).
Dispatch is a flat 39-case if-chain — table-driven `array.new_data`
over per-name data segments would shave ~5 KB of wasm code, but
requires coordination with module.rs's data-section emit; deferred.

New test http_get_surfaces_connection_refused binds to port 0
then closes the listener, calls Http.get against the now-free
port, asserts the surfaced Err contains a recognisable
connection-refused / connection-failed / connection-terminated
message (host TCP backoff sometimes routes through handle()'s
retptr Err arm rather than future.get's inner Err — both paths
are valid; the assertion accepts either).
Generalises the helper from `__rt_http_get(url)` to
`__rt_http_request(method_tag, url)`. method_tag uses wasi:http's
`method` variant ordinals (0=GET, 1=HEAD, 4=DELETE; 2=POST,
3=PUT, 8=PATCH land in Step K).

Adds [method]outgoing-request.set-method slot. After the request
constructor, a single `if method_tag != 0` gates the set-method
call, keeping the GET fast path identical to the original
helper. Constructor defaults to GET, so calling set-method only
when needed avoids an extra host round-trip on the most common
verb.

Per-method dispatch refactored to a shared `emit_http_method_
wasip2(name, tag, ...)` in body/builtins_wasip2.rs; `Http.get`,
`Http.head`, `Http.delete` thin wrappers each push the right
ordinal. Slot registration in module.rs broadens to match
`HttpGet | HttpHead | HttpDelete`; effect_check.rs graduates
all three on wasip2 (rejects post/put/patch with "Phase 2.K /
0.19" hint).

New test http_head_and_delete_dispatch_correct_method spawns a
custom Python BaseHTTPRequestHandler tagging responses with
`X-Method: <verb>`. Aver fires GET/HEAD/DELETE in turn and the
test asserts each surfaces the matching method header. HEAD also
asserts an empty body (server skips wfile.write per HTTP spec).
Generalises __rt_http_request to take 5 params (method_tag, url,
content_type, body, headers map). Body-less methods (GET/HEAD/
DELETE) keep their per-call dispatchers; the new body-bearing
dispatchers push user args verbatim. Both helper variants share
the same underlying fn — body marshalling gates on
`method >= 2 && method != 4` so GET/HEAD/DELETE skip it
entirely.

Five new wasi:http slots:
- [method]outgoing-request.body → result<own<outgoing-body>>
- [method]outgoing-body.write → result<own<output-stream>>
- [static]outgoing-body.finish → result<_, error-code>
- [method]fields.append → result<_, header-error> (via retptr —
  flat encoding flattens to 2 i32s, exceeds MAX_FLAT_RESULTS)
- [resource-drop]outgoing-body (error path between body() and
  finish())

Reused: OutputStreamBlockingWriteAndFlush + drop, both already
wired by Console.print.

Bug found mid-implementation: the URL parser leaves auth_ptr /
path_ptr pointing into LM[0..url_len], but the to_lm calls for
header values + Content-Type + body all clobber LM[0..]. Fix:
copy authority + path bytes to the cabi_realloc bump heap right
after URL parse, repoint the locals at the bump heap. set-
authority / set-path-with-query then read from a stable address
that no subsequent to_lm can corrupt.

User-headers iteration walks the Map<String, List<String>> by
cap-iter — ArrayGet keys[i], if non-null walk values[i] cons-list
and fields.append per (key, value) pair. Each pair allocates a
cabi_realloc'd scratch for the key bytes (val bytes go to
LM[0..val_len] via to_lm; the scratch keeps them non-overlapping
so the host reads both correctly).

Content-Type is appended after user headers, before the request
constructor (which transfers fields ownership). v1 doesn't set
Content-Length, so wasmtime-wasi-http defaults to Transfer-
Encoding: chunked — the test server includes a small chunked
decoder to handle this.

Test http_post_put_patch_round_trip_body_and_headers spawns a
custom Python server that echoes the request body + surfaces
Content-Type (X-Echo-Type) + a custom user header (X-Echo-Custom)
back. Aver fires POST/PUT/PATCH in turn; the test asserts each
round-trip preserves method, body, content-type, and the user-
provided X-Custom header — covering body marshalling, headers
iteration, and method dispatch end-to-end.

effect_check.rs graduates all six Http.* methods on wasip2;
the only remaining wasip2-rejected effects are Tcp.* and
HttpServer.* (Phase 3 / 0.19+).
@jasisz jasisz changed the title http on --wasip2 (PoC: Http.get) http on --wasip2 (all 6 methods + headers) May 9, 2026
jasisz added 3 commits May 9, 2026 22:43
Step K shipped without setting Content-Length, forcing wasmtime-
wasi-http to fall back to Transfer-Encoding: chunked. Most
modern HTTP servers handle chunked but Python's stdlib
BaseHTTPRequestHandler doesn't, which forced the test to inline
a chunked decoder. Setting Content-Length explicitly is also
more honest HTTP — receivers know the body size up front.

Inline int→decimal in __rt_http_request:
- body_len = array.len p_body  (no copy; the (array i8) carries
  the byte count directly)
- alloc 16-byte cabi_realloc'd scratch
- do-while writes digits backwards from buf+16 (always at least
  one digit so body_len=0 surfaces "0")
- fields.append(fields, "Content-Length", digit_ptr, digit_len)

Adds 4 i32 locals (cl_body_len, cl_buf, cl_pos, cl_n) at the
end of the local table so existing ref-typed indices don't
shift. Test server reverts to plain Content-Length read.

Also fixes the WASM clippy gate (`-D warnings`): three doc-
lazy-continuation lints from Step K's helper docs (proza ze
znakami `+` traktowana jako bullet list). Restructured the
prose to use commas instead of `+`-separated phrases and added
blank-doc-line separators between bullet lists and following
prose.
…fused test

Three Err paths in the body-marshalling block left the outgoing-
request alive after bailing (handle() never runs to transfer
ownership), leaking one resource per error:

- request.body Err → req leaked
- body.write Err → body + req leaked (was dropping body only)
- body.finish Err → body gone but req still alive

Each path now drops the appropriate resource(s) child-before-
parent before emit_err. The drop_outgoing_request slot was
already registered for exactly this case.

Also makes http_get_surfaces_connection_refused deterministic.
The previous version bound to port 0 and dropped the listener,
hoping a connect() to the (now-free) port within ~10s would
get RST. The kernel can reuse the port before our connect
fires, masking the refused signal. Switched to port 1 (well-
known tcpmux, never legitimately listening on dev machines)
and broadened the assertion to "err=http: <any error-code>"
rather than enumerating specific variant names — the test's
contract is "the dispatch surfaced a recognised HTTP error,
not a panic," not "host emitted exactly connection-refused."
Five consecutive runs stable.
Adds an unreleased 0.19.0 entry covering the six Http.* methods
on --target wasip2; codename + tagline TBD by user before tag.
Updates docs/wasip2.md effect-mapping table — Http.* no longer
rejected, only Tcp.* / HttpServer.* remain compile-rejected on
the wasip2 target.
@jasisz jasisz marked this pull request as ready for review May 12, 2026 19:34
@jasisz jasisz merged commit 196b004 into main May 12, 2026
4 checks passed
@jasisz jasisz deleted the 0.19-http-wasip2 branch May 12, 2026 19:34
jasisz added a commit that referenced this pull request May 13, 2026
HTTP both ways: client (PR #25) + server (PR #26) on `--target wasip2`.
Same source can call out via `Http.{get, head, delete, post, put,
patch}` and answer back via `HttpServer.listen` (`--world wasi:http/
proxy --handler <fn>`). Same `.component.wasm` verified portable
across Cranelift (wasmtime serve) and V8 (jco serve on Node 22).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant