Ingle std/http — Design of Record
Decided 2026-06-19. This is a binding design: the API and the transport model below are the plan; the
modules are built against it. Decisions trace to MANIFESTO.md. Supersedes the
ad-hoc extern http_post the desktop apps use today.
Thesis
HTTP is structured-concurrency I/O, not async/await. A request is a blocking-looking call run on a
fiber; a streaming body is a Channel<…> that a worker fiber feeds. No futures, no .await, no function
coloring, no Send + Sync virality — the things the manifesto rejects in Rust async (§3.2). We take Go’s
“blocking call on a cheap thread” ergonomics and fix Go’s footguns (leaked undrained bodies, one coarse
deadline) structurally, because the nursery scope owns the request and tears it down on exit (§5,
concurrency primitive).
The transport model — PULL via an opaque handle (not a push native)
The research draft proposed a runtime-aware native whose libcurl write-callback calls em_channel_send.
Rejected for the MVP in favour of a simpler, more on-thesis model:
Streaming is
curl_multibehind aPtrhandle, driven by pull. The transport is three plainextern "c"leaves; the Ingle worker fiber owns the channel and does thesends itself.
extern "c" {
fn http_open(url: string, headers: string, body: string) -> Ptr // POST, returns a stream handle
fn http_next(h: Ptr) -> string // next body chunk; "" at end
fn http_status(h: Ptr) -> int // HTTP status (0 until known)
fn http_close(h: Ptr) // free the handle
}
http_next pumps curl_multi_perform/curl_multi_poll until a chunk arrives or the transfer ends, then
returns it. Chunks arrive incrementally as the network delivers them — true streaming. Why this beats
the push native: it needs ZERO runtime/checker changes (it’s the existing fopen/fread/fclose
Ptr leaf-FFI pattern, §5h, OFI-049 made Ptr move-only/safe); it keeps concurrency 100% Ingle
(fibers + channels), which is more on-thesis; and the blocking lives in http_next on a worker fiber,
exactly like http_post blocks today. The push-native is only worth it for the later curl_multi-reactor
phase (one thread, thousands of connections) — see Phase 1.
The Ingle API surface (std/http)
enum Method { Get, Post, Put, Delete, Patch, Head, Options }
enum HttpError { Dns(string), Connect(string), Tls(string), Timeout, Cancelled, Protocol(string) }
struct Request { /* method, url, headers, body, timeouts */ }
fn get(url: string) -> Request
fn post(url: string) -> Request
fn Request.header(mut self, name: string, value: string) -> Request
fn Request.body(mut self, s: string) -> Request
struct Response { /* status, headers, body */ }
fn Response.status(self) -> int // a FIELD, not an error (decision D2)
fn Response.text(self) -> string // full body (90% path)
fn Response.header(self, name: string) -> Option<string>
// blocking full-body (the simple path) — runs on the calling fiber:
fn send(req: Request) -> Result<Response, HttpError>
// streaming: the worker pumps http_next into `out`, closing it at end-of-stream. The caller spawns it
// in a nursery and drains `out`; nursery-exit cancels the in-flight request (structural cancellation).
fn pump(req: Request, out: Channel<string>)
SSE is a thin layer that never lives in std/http:
// std/sse — Channel<string> (raw bytes) -> Event stream. Pull-based, no callbacks (§5g).
struct Event { name: string, data: string }
fn feed(decoder: mut Decoder, bytes: string) -> [Event]
JSON is its own module (std/json, decision D3) so std/http stays protocol-agnostic and the fragile
split("\"text\":\"") in the apps dies.
Status — realized 2026-06-20
The first shipped std/http.ig is a thin wrapper over the C externs, not yet the Request/Response
builder above. It exposes exactly the surface the desktop apps already used inline, lifted into one module:
fn post(url: string, headers: string, body: string) -> string // blocking POST, whole body at once
fn get(url: string, headers: string) -> string // blocking GET (short timeouts), whole body
fn open(url: string, headers: string, body: string) -> Ptr // streaming POST → handle
fn next(h: Ptr) -> string // next chunk ("" at end of stream)
fn status(h: Ptr) -> i64
fn close(move h: Ptr) -> i64 // consumes the linear handle
get (added 2026-06-23) is the read counterpart to post: a blocking GET with a short connect/total
timeout (4 s / 20 s) so a discovery probe against a down or slow host fails fast instead of hanging the
caller. It was driven by the Claude app’s local‑model support — listing installed Ollama models is a
GET /api/tags, and the streaming open/post are both POST‑only. A transfer failure comes back as the
same {"_curl_error":"…"} sentinel string post uses, so the caller always gets an inspectable string.
chat.ig (blocking http.post) and the new reusable anthropic client (streaming http.open/next/
close, fed to std/sse) both import it; the inline extern http_post/http_open blocks are gone —
Phase 0’s “delete extern http_post from both apps” is done. The richer builder API (Request/
Response/Method/HttpError, send/pump) remains the planned evolution, and must design around one
real constraint: Ptr is a linear type that cannot be a struct field (OFI-049’s erasure-proof
type-formation ban), so a Response object cannot simply hold an open stream handle — the streaming
surface stays handle-passing (open/next/close) until that is resolved. Lifting the spawnable
streaming worker into a library module also surfaced and closed OFI-091 (qualified-callee spawn).
Claude’s streaming loop (the dogfood target)
nursery {
spawn http.pump(req, raw) // raw: Channel<string>
var dec = sse.decoder()
loop {
match recv(raw) {
None => break // stream closed
Some(bytes) => {
for ev in sse.feed(dec, bytes) {
if ev.name == "content_block_delta" {
send(tokens, json.text_delta(ev.data)) // typewriter → UI channel
}
}
}
}
}
}
N parallel requests = N spawns in a nursery; the nursery joins (and cancels laggards) on exit.
Decisions (D1–D6, decided 2026-06-19)
- D1 — streaming body is
Channel<string>(bytes). A fiber onrecv()already is a blocking reader, with backpressure + scope-cancellation free. NoReadertype. (§3.2, §5) - D2 — HTTP status is a
Responsefield, not an error.sendfails only on transport. Fixes today’s real bug (a 401 body becoming the “reply”). (§2.4, errors-as-values) - D3 — three modules, one milestone:
std/http+std/sse+std/json. Composable, small surface. - D4 — capabilities (
Net/Fs/Clock/…) are adopted as the NEXT language milestone, not here. Capability-passing (fn f(net: Net)— code provably can’t touch the network without the token) is the strongest expression of the LLM-first principle (§5b): a reader knows a function’s authority from its signature. It is not yet in the manifesto (the research over-cited a “§5i”); it is a new decision to be specced and added manifesto-wide (it touchesmain, the runtime root-grant, and all effectful stdlib). http ships without it now and gainsNetthen — breaking is fine, we are pre-1.0. - D5 — libcurl through Phase 1. TLS/h2/redirects/gzip for free; it is the one blessed opt-in dep (raylib precedent, §3.5). Pure-Ingle sockets+TLS is a separate future project; the API makes the swap non-breaking.
- D6 — fibers: the “M:N green threads” gap is resolved. The M:N scheduler is built (OFI-071,
gated behind
EMBER_MN); the default build keeps the 1:1 OS-thread-per-spawnmodel that this design’s Phase 0 assumes (one OS thread per in-flight request).
Implementation phases
- Phase 0 — MVP (this milestone). The three
curl_multiPtrexterns above +std/http(get/post/send/pump/Response) +std/sse+std/json(real parse). Convertflare_chat.igto a streaming typewriter and deleteextern http_post/build_request/extract_textfrom both apps. Tradeoff: one OS thread per in-flight request (fine for a chat app, 1–4 concurrent). Cancellation is real but coarse (aborts at the nexthttp_nextpump, viaCURLOPT_XFERINFOFUNCTION). - Phase 1 — world-class. A single
curl_multireactor on the fiber scheduler: thousands of connections on ~1 thread, per-host keep-alive/pooling (kills per-turn TLS handshakes), redirects, retry/backoff honouringRetry-After. Samestd/httpAPI — only the transport guts change. Here a runtime-aware native (the rejected push model) becomes the right tool, because the reactor’s callbacks no longer run on the caller’s thread.
Manifesto trace
| Decision | Principle |
|---|---|
Blocking send/pump, no async/await/futures |
§3.2 (no function coloring) |
nursery { spawn } + Channel<T>, one runtime, scope-cancellation |
§5 (concurrency primitive) |
Result<Response, HttpError> + ?; status is a field; Option headers |
errors-as-values, no null |
Streaming chunks are owned strings, never escaping borrowed slices |
§5f, OFI-009 |
| Pull channel reads, never registered callbacks | §5g (no hidden control flow) |
libcurl opt-in, off the default make/make test path; never named in Ingle |
§3.5, §5g (raylib precedent) |
Transport is Ptr-handle leaf FFI, copy-out, no C-owned pointer escapes |
§5h, OFI-043/049 |
| One canonical client shape, least-surprise names | §5b |
(later) Net capability makes network authority legible |
§5b (LLM-first) |