Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ For the release process and tag conventions, see [RELEASING.md](RELEASING.md).

### Added

- **`ilo httpd` lazy streaming response body (ILO-482).** A handler's response `body` field may now be a lazy line iterator (`get-stream` / `pst-stream`, `for-line stdin`) in addition to a plain string or an eager `L t` list. When the body is a lazy iterator, `ilo httpd` writes and flushes each yielded line as its own chunked-transfer block as soon as the handler produces it, rather than materialising the whole body first. This lets a handler hold the connection open and emit chunks incrementally (true SSE / long-poll / tailing a growing source). If the client disconnects mid-stream the connection thread drops the iterator and exits cleanly with no panic. The existing string and `L t` body shapes are unchanged. Unblocks `ilo-lang/crew`'s `crew-server` `GET /events/stream`, intended as a held-open tail of `data/feed/<day>.jsonl` but stuck on a one-shot snapshot while the body buffered. Follow-ups: a `tail-file` source (lazy `tail -f`) for the file-tail case, and `get-stream`'s 16 KiB read-buffer granularity for sub-buffer payloads. See `docs/streaming.md` and `examples/httpd-stream.ilo`.
- **`ilo httpd` resolves `use` imports (ILO-481).** Handler files loaded by `ilo httpd` now have their `use` imports resolved at startup, relative to the handler's own directory, matching the existing `ilo run` / `ilo check` semantics. Previously `httpd` lexed, parsed, and verified only the single handler file and silently skipped import resolution, so a handler could not `use` a sibling module - `ilo-lang/crew`'s `crew-server` had to inline ~140 lines of store logic to work around it. A missing module now surfaces a real import diagnostic and the server refuses to start instead of failing later with a generic verifier error. See `docs/streaming.md`.
- **`spawn` builtin (ILO-477).** `spawn fn args... > _` runs `fn args...` on a background OS thread, fire-and-forget. Returns nil immediately; errors and panics inside the thread go to stderr and the thread dies, while the parent is unaffected. Caps are inherited from the parent via `Arc::clone(&env.caps)`, so a worker started under `--allow-net` / `--allow-write` keeps the same policy. Unblocks daemon-style programs that need multiple concurrent loops in one process - canonical case is `ilo-lang/crew`'s per-machine agent (MCP HTTP server foreground + SSE consumer background + write-behind queue drainer background). Out of scope for v1 (separate tickets): join handles, channels, supervision, cancellation tokens, async runtime, native VM / Cranelift codegen. Tree-walker only at runtime; VM and Cranelift inherit through the existing tree bridge. See `examples/daemon-loops.ilo`.
- **Client-side HTTP streaming (ILO-448).** Four new builtins that return a lazy `L t` line iterator over a chunked / SSE response body: `get-stream url`, `get-stream-h url headers`, `pst-stream url body`, `pst-stream-h url body headers`. Consume via `@line (get-stream url){...}` - one chunk-line per iteration, body never fully buffered. Cap-checked via `--allow-net` before opening the connection; mid-stream I/O errors surface as `ILO-R009 http-stream read error: ...`. WASM returns `Err`. Symmetric counterpart to the server-side `ilo httpd` + chunked transfer encoding shipped in ILO-46 / ILO-379; unblocks `ilo-lang/crew`'s per-machine agent daemon needing an SSE consumer. Tree + VM only in this release; Cranelift JIT follow-up. See `docs/streaming.md` and `examples/sse-client.ilo`.
Expand Down
10 changes: 9 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -2566,7 +2566,9 @@ Handler signature:
--
-- Response fields read by ilo httpd:
-- status:n HTTP status code (200, 404, 500, ...)
-- body:t response body
-- body response body: t (buffered), L t (eager chunked),
-- or a lazy iterator (get-stream / for-line stdin) for
-- true incremental streaming
-- headers:M t t optional response headers

type rsp{status:n;body:t}
Expand All @@ -2581,6 +2583,12 @@ Use `req:_` (wildcard) for the request param type — the `Request` record is cr

The handler file's `use` imports are resolved at startup, relative to the handler's own directory, matching `ilo run` / `ilo check` (ILO-481). A handler can split logic across sibling modules (`use "store.ilo"`) rather than inlining everything. A missing import surfaces a real diagnostic and the server refuses to start.

The response `body` field may take three shapes (ILO-482):

* `t` — a plain string, sent with `Content-Length` (the default).
* `L t` — a list of strings, sent eagerly with `Transfer-Encoding: chunked`: each element becomes one chunk. The list is materialised before the first byte is written.
* a lazy line iterator (`get-stream`/`pst-stream`, `for-line stdin`) — sent with `Transfer-Encoding: chunked` **lazily**: each line the iterator yields is written and flushed as its own chunk, so the handler can hold the connection open and emit chunks as they are produced (SSE, long-poll, tailing a growing source) without buffering the whole body first. If the client disconnects mid-stream the connection thread exits cleanly. A zero-arg `body` function (`FnRef`/closure) is called first and may itself return any of the three shapes.

**`ilo check --strict`.** Treats every warning-severity diagnostic (ILO-T032 bare `fmt`, ILO-T033 bare `mset` / `+=` / `mdel`, ILO-W002 `@x (jpar! …){…}` steering to `jpar-list!`, future warning codes) as a hard exit-code failure. The diagnostic stream itself is unchanged: warnings still emit with `severity: "warning"` in the JSON output, so editor integrations that route by severity stay correct. Only the exit code is elevated. CI harnesses that gate merges on `ilo check` should use `--strict` so warnings can't slip through silently; for interactive use, the default (warnings-are-advisory) is the right behaviour.

**Default-run.** Inline programs (`ilo 'code'`) and single-function files run their entry function with the remaining CLI args; no explicit function name needed. Multi-function files auto-pick a function called `main` when no positional func arg is supplied. The same heuristic applies to the explicit engine flags - `--vm` and `--jit` both auto-pick `main` on multi-fn files, matching the default-engine behaviour. With no `main` declared, supply a function-name argument.
Expand Down
2 changes: 1 addition & 1 deletion ai.txt

Large diffs are not rendered by default.

41 changes: 41 additions & 0 deletions docs/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,46 @@ relative to the handler's own directory, exactly like `ilo run` and
(`use "store.ilo"`) instead of inlining everything in one file. A missing
import surfaces a real diagnostic and the server refuses to start.

### Response body shapes

The response `body` field takes one of three shapes:

| Body value | Wire format | Buffering |
|---|---|---|
| `t` (string) | `Content-Length` | n/a (already a string) |
| `L t` (list) | `Transfer-Encoding: chunked` | list materialised before first byte |
| lazy iterator | `Transfer-Encoding: chunked` | **none** — streamed line by line |

A lazy iterator body (ILO-482) is the SSE / long-poll / file-tail path:
each line the iterator yields is written and flushed as its own chunk as
soon as the handler produces it, so the connection can be held open
indefinitely and the body is never fully buffered. Any value that drains
lazily works as the body:

```ilo
type rsp{status:n;body:_}

-- Proxy an upstream SSE / chunked source straight through, streaming.
handler req:_>rsp
rsp status:200 body:(get-stream "http://localhost:7778/events/stream")
```

`for-line stdin` works the same way (tail stdin line by line). If the
client disconnects mid-stream the connection thread drops the iterator
(closing any upstream connection / open file) and exits cleanly with no
panic. A zero-arg `body` function is called first and may itself return
any of the three shapes.

> **Note (`get-stream` granularity).** `get-stream`'s underlying reader
> fills a 16 KiB backing buffer before yielding a line, so when proxying
> an upstream whose total body is smaller than that, lines can arrive in
> one batch rather than one at a time. The httpd plumbing itself streams
> per line; the batching is a `get-stream` buffer-size artifact tracked
> as a follow-up. A `tail-file` source (a lazy `tail -f` over a growing
> file, which crew's `/events/stream` needs) is the other follow-up.

Reference: `examples/httpd-stream.ilo`.

## Buffered HTTP (unchanged)

For request/response patterns where the entire body is small and easy to
Expand Down Expand Up @@ -83,3 +123,4 @@ For stdin streaming, see `for-line stdin` (ILO-70).
* ILO-379 — chunked transfer-encoding for `ilo httpd`
* ILO-448 — client-side HTTP streaming builtins (`get-stream`, `pst-stream`, …)
* ILO-481 — `ilo httpd` resolves `use` imports in handler files
* ILO-482 — `ilo httpd` lazy streaming response body (handler-driven SSE)
25 changes: 25 additions & 0 deletions examples/httpd-stream.ilo
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- ilo httpd lazy streaming response body (ILO-482).
--
-- A handler whose `body` field is a lazy line iterator streams each line to
-- the client as it is produced, instead of buffering the whole body first.
-- This lets a handler hold the connection open for SSE / long-poll / a tail
-- of a growing source. The eager `L t` body (a list of chunks) still works
-- unchanged; the lazy body is an additive response shape.
--
-- Any value that drains lazily can be the body:
-- * `get-stream url` proxy an upstream chunked / SSE response (shown here)
-- * `for-line stdin` tail stdin line by line
--
-- Run it:
-- ilo httpd examples/httpd-stream.ilo --port 7777
-- curl -N http://localhost:7777/ # -N: no client-side buffering
--
-- Each upstream line is re-emitted as its own chunk and flushed immediately,
-- so `curl -N` prints lines as the upstream produces them rather than all at
-- once at the end. There is no `-- run:` assertion because it needs a live
-- server; see tests/httpd_streaming.rs for runnable coverage.

type rsp{status:n;body:_}

handler req:_>rsp
rsp status:200 body:(get-stream "http://localhost:7778/events/stream")
21 changes: 21 additions & 0 deletions src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,27 @@ impl HttpLinesHandle {
}
}

/// A backend-agnostic lazy line source, pulled one line at a time.
///
/// Wraps the two existing pull-based iterators ([`StdinLinesHandle`],
/// [`HttpLinesHandle`]) behind a single `next_line()` so callers outside the
/// interpreter (e.g. `ilo httpd`'s streaming response body, ILO-482) can drain
/// a handler-returned lazy body without caring which source produced it.
pub enum LazyLines {
Stdin(StdinLinesHandle),
Http(HttpLinesHandle),
}

impl LazyLines {
/// Pull the next line from the underlying iterator, or `None` at end.
pub fn next_line(&self) -> Option<std::result::Result<String, std::io::Error>> {
match self {
LazyLines::Stdin(h) => h.next_line(),
LazyLines::Http(h) => h.next_line(),
}
}
}

#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Number(f64),
Expand Down
92 changes: 91 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1179,10 +1179,18 @@ fn handle_http_connection(
other => other,
};

// Body shape: either a plain string or a list of chunks for chunked transfer.
// Body shape: either a plain string, an eagerly-collected list of chunks,
// or a lazy line iterator whose chunks are pulled and written one at a
// time (true streaming / SSE, ILO-482).
enum BodyShape {
Plain(String),
Chunked(Vec<String>),
/// A pull-based line iterator. Each `next_line()` is written as its
/// own chunk and flushed immediately, so a handler can hold the
/// connection open and emit chunks as they are produced (e.g. tailing
/// a growing file, proxying an upstream SSE source) without buffering
/// the whole body first.
Lazy(interpreter::LazyLines),
}

let (status, resp_headers, body_shape) = match &resp {
Expand All @@ -1197,6 +1205,16 @@ fn handle_http_connection(
// FnRef/Closure → call it (no args) expecting a List, then chunk
let body_shape = match fields.get("body") {
Some(Value::Text(s)) => BodyShape::Plain((**s).clone()),
// Lazy line iterators (ILO-482): a handler returning
// `get-stream`/`for-line stdin` (or, once it lands, a
// file-tail iterator) gets each line written as its own chunk
// as the iterator yields, with no full-body buffering.
Some(Value::LazyHttpLines(h)) => {
BodyShape::Lazy(interpreter::LazyLines::Http(h.clone()))
}
Some(Value::LazyStdinLines(h)) => {
BodyShape::Lazy(interpreter::LazyLines::Stdin(h.clone()))
}
Some(Value::List(items)) => {
let chunks = items.iter().map(|v| v.to_string()).collect();
BodyShape::Chunked(chunks)
Expand All @@ -1207,6 +1225,12 @@ fn handle_http_connection(
let chunks = items.iter().map(|v| v.to_string()).collect();
BodyShape::Chunked(chunks)
}
Ok(Value::LazyHttpLines(h)) => {
BodyShape::Lazy(interpreter::LazyLines::Http(h))
}
Ok(Value::LazyStdinLines(h)) => {
BodyShape::Lazy(interpreter::LazyLines::Stdin(h))
}
Ok(other) => BodyShape::Plain(other.to_string()),
Err(e) => BodyShape::Plain(format!("chunk-fn error: {}", e)),
}
Expand All @@ -1217,6 +1241,12 @@ fn handle_http_connection(
let chunks = items.iter().map(|v| v.to_string()).collect();
BodyShape::Chunked(chunks)
}
Ok(Value::LazyHttpLines(h)) => {
BodyShape::Lazy(interpreter::LazyLines::Http(h))
}
Ok(Value::LazyStdinLines(h)) => {
BodyShape::Lazy(interpreter::LazyLines::Stdin(h))
}
Ok(other) => BodyShape::Plain(other.to_string()),
Err(e) => BodyShape::Plain(format!("chunk-fn error: {}", e)),
}
Expand Down Expand Up @@ -1303,6 +1333,66 @@ fn handle_http_connection(
// Terminating chunk
writer.write_all(b"0\r\n\r\n")?;
}
BodyShape::Lazy(lines) => {
// Lazy streaming body (ILO-482): write the chunked header block,
// then pull each line from the iterator and flush it as its own
// chunk so the client sees data as soon as the handler produces
// it. The body is never fully buffered, so the connection can be
// held open indefinitely (SSE, long-poll, file tail).
let mut header_block = format!("HTTP/1.1 {} {}\r\n", status, status_text);
if !has_content_type {
header_block.push_str("Content-Type: text/plain; charset=utf-8\r\n");
}
for (k, v) in &resp_headers {
header_block.push_str(&format!("{}: {}\r\n", k, v));
}
header_block.push_str("Transfer-Encoding: chunked\r\n");
header_block.push_str("Connection: close\r\n");
header_block.push_str("\r\n");
writer.write_all(header_block.as_bytes())?;
writer.flush()?;

loop {
match lines.next_line() {
Some(Ok(line)) => {
// Re-attach the newline the line iterator strips, so a
// client doing line-oriented reads (SSE) sees a record
// boundary per chunk.
let mut data = line.into_bytes();
data.push(b'\n');
// A failed write means the client hung up mid-stream.
// Drop the iterator (closing any upstream connection /
// file) and exit the thread cleanly rather than
// panicking.
if writer
.write_all(format!("{:x}\r\n", data.len()).as_bytes())
.and_then(|_| writer.write_all(&data))
.and_then(|_| writer.write_all(b"\r\n"))
.and_then(|_| writer.flush())
.is_err()
{
eprintln!(
"{} {} {} -> {} (client disconnected)",
peer, method, path, status
);
return Ok(());
}
}
Some(Err(e)) => {
// Mid-stream read error from the source. Best effort:
// close the chunked stream and stop.
eprintln!("stream read error: {}", e);
let _ = writer.write_all(b"0\r\n\r\n");
let _ = writer.flush();
return Ok(());
}
None => break,
}
}
// Terminating chunk.
let _ = writer.write_all(b"0\r\n\r\n");
let _ = writer.flush();
}
}

eprintln!("{} {} {} -> {}", peer, method, path, status);
Expand Down
33 changes: 22 additions & 11 deletions tests/httpd_imports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,9 @@
//! reachable over HTTP, that a missing import surfaces a diagnostic, and that
//! plain single-file handlers still work.

use std::io::{Read, Write};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::process::{Child, Command};
use std::time::{Duration, Instant};

fn ilo() -> Command {
Command::new(env!("CARGO_BIN_EXE_ilo"))
Expand All @@ -23,10 +22,17 @@ fn free_port() -> u16 {
listener.local_addr().expect("local_addr").port()
}

/// Spawn `ilo httpd --port <port> <handler>` and wait until the port accepts
/// connections (or time out). Returns the child so the caller can kill it.
/// Spawn `ilo httpd --port <port> <handler>` and wait until it logs that it is
/// listening (or time out). Returns the child so the caller can kill it.
///
/// Readiness is detected by reading the child's stderr for the
/// `ilo httpd listening on` line, NOT by probing the port with a TCP connect.
/// A raw connect probe is itself an accepted connection that httpd dispatches
/// to a handler thread; under load that spurious startup request races the
/// real test request (ILO-505). Waiting on the log line avoids running the
/// handler during startup at all.
fn spawn_httpd(handler: &std::path::Path, port: u16) -> Child {
let child = ilo()
let mut child = ilo()
.args([
"httpd",
"--port",
Expand All @@ -38,13 +44,18 @@ fn spawn_httpd(handler: &std::path::Path, port: u16) -> Child {
.spawn()
.expect("spawn ilo httpd");

// Poll the port until it's listening.
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
return child;
// Read stderr until the server logs that it is listening, rather than
// probing the port (which would trigger a spurious startup handler call).
let stderr = child.stderr.take().expect("piped stderr");
let mut reader = BufReader::new(stderr);
for _ in 0..100 {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break; // EOF: server exited before logging readiness
}
if line.contains("listening on") {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
child
}
Expand Down
Loading
Loading