Files
sx/examples/1633-http-server.sx
agra 721793b4bf feat: std.http — single-worker HTTP/1.1 server core (PLAN-HTTPZ S7a)
The httpz shape, one worker, handlers inline over the std.event Loop:
nonblocking accept, per-connection state machine (reading -> writing ->
keepalive/close) with incremental parsing (request line, headers,
Content-Length body), partial-write continuation via on-demand write
interest, pipelined-request draining, and timeouts as EVICTION —
request-delivery and keepalive-idle deadlines on the monotonic clock,
checked after I/O each tick. Keep-alive is the HTTP/1.1 default;
Connection header, HTTP/1.0, or the per-connection request_count cap
turn it off. Config mirrors httpz: port/backlog/max_conn/read_buf_cap/
timeout_request_ms/timeout_keepalive_ms/request_count.

API: Server.init(cfg, handler) + tick(max_wait_ms); run() is the
forever-tick loop. tick makes the server drivable single-threaded —
examples/1633 runs a live server and its client sockets in ONE thread,
pinning: GET with keep-alive, actual connection reuse, the request cap
answering Connection: close then EOF, POST body echo, 404 routing, and
a half-header client evicted at the request deadline while a healthy
client keeps being served. Verified under sx run AND sx build.

Connection slots and read buffers are reused across connections
(httpz's min_conn/buffer-pool spirit); response buffers are allocated
per response and freed on completion. Serialization happens while
request views are valid, the served bytes are compacted, and only then
does sending start — write_more's pipelining check must see only the
remainder. The std.sx barrel carries http; .ir snapshot regen is the
usual mechanical renumbering.

S7b adds worker counts + the handler thread pool (needs C2/S6); the
epoll backend activates with the linux target (S4/S7c).
2026-06-12 21:16:56 +03:00

186 lines
6.9 KiB
Plaintext

// std.http S7a (PLAN-HTTPZ): a live single-worker server and its
// clients driven in ONE thread via Server.tick — keep-alive reuse,
// POST body echo, the per-connection request cap closing politely,
// 404 routing, and half-a-header eviction at the request deadline
// while the server keeps serving others.
#import "modules/std.sx";
PORT :: 18933;
handler :: (req: *http.Request, resp: *http.Response) {
if req.path == "/hello" {
resp.body = concat("hello ", req.method);
return;
}
if req.path == "/echo" {
resp.body = req.body;
return;
}
resp.status = 404;
resp.body = "nope";
}
contains :: (hay: string, needle: string) -> bool {
if needle.len > hay.len { return false; }
i := 0;
while i + needle.len <= hay.len {
j := 0;
ok := true;
while j < needle.len {
if hay[i + j] != needle[j] { ok = false; break; }
j += 1;
}
if ok { return true; }
i += 1;
}
return false;
}
// Connect a nonblocking loopback client.
dial :: () -> i32 {
fd := socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0);
if fd < 0 { return -1; }
addr : socket.SockAddr = .{
sin_len = 16, sin_family = xx socket.AF_INET,
sin_port = socket.htons(PORT), sin_addr = 0x0100007F,
};
if socket.connect(fd, @addr, 16) != 0 { socket.close(fd); return -1; }
if !socket.set_nonblocking(fd) { socket.close(fd); return -1; }
return fd;
}
// True when `buf[0..len]` holds a complete response (headers + body).
resp_complete :: (buf: [*]u8, len: i64) -> bool {
s := string.{ ptr = buf, len = xx len };
he := -1;
i := 0;
while i + 3 < s.len {
if s[i] == 13 and s[i+1] == 10 and s[i+2] == 13 and s[i+3] == 10 { he = i; break; }
i += 1;
}
if he < 0 { return false; }
// Content-Length digits
cl : i64 = 0;
seen := false;
j := 0;
needle := "Content-Length: ";
while j + needle.len < s.len {
k := 0;
ok := true;
while k < needle.len { if s[j + k] != needle[k] { ok = false; break; } k += 1; }
if ok {
d := j + needle.len;
while d < s.len and s[d] >= 48 and s[d] <= 57 { cl = cl * 10 + (s[d] - 48); d += 1; }
seen = true;
break;
}
j += 1;
}
if !seen { return false; }
return len >= he + 4 + cl;
}
// Send a request and tick the server until its full response arrives.
// Returns the response text ("" = the connection closed instead).
roundtrip :: (srv: *http.Server, fd: i32, reqtext: string, scratch: [*]u8) -> string {
socket.write(fd, reqtext.ptr, xx reqtext.len);
total : i64 = 0;
tries := 0;
while tries < 400 {
srv.tick(5) catch {};
n, re := socket.read_nb(fd, @scratch[total], xx (4096 - total));
if !re { total += n; }
else if re == error.Closed { return string.{ ptr = scratch, len = xx total }; }
if resp_complete(scratch, total) { return string.{ ptr = scratch, len = xx total }; }
tries += 1;
}
return "";
}
main :: () -> i32 {
cfg : http.Config = .{
port = PORT,
timeout_request_ms = 150,
timeout_keepalive_ms = 400,
request_count = 3,
max_conn = 8,
};
srv, se := http.Server.init(cfg, handler);
if se { print("server init failed\n"); return 1; }
buf : [4096]u8 = ---;
// ── 1. GET, keep-alive default ────────────────────────────────────
c1 := dial();
if c1 < 0 { print("dial failed\n"); return 1; }
r1 := roundtrip(@srv, c1, "GET /hello HTTP/1.1\r\nHost: t\r\n\r\n", @buf[0]);
if !contains(r1, "HTTP/1.1 200 OK") { print("case1: bad status\n"); return 1; }
if !contains(r1, "hello GET") { print("case1: bad body\n"); return 1; }
if !contains(r1, "Connection: keep-alive") { print("case1: expected keep-alive\n"); return 1; }
print("GET 200, keep-alive\n");
// ── 2. same socket again: the connection was actually reused ─────
r2 := roundtrip(@srv, c1, "GET /hello HTTP/1.1\r\nHost: t\r\n\r\n", @buf[0]);
if !contains(r2, "hello GET") { print("case2: keep-alive reuse failed\n"); return 1; }
print("keep-alive reuse ok\n");
// ── 3. third request hits request_count: Connection: close + EOF ─
r3 := roundtrip(@srv, c1, "GET /hello HTTP/1.1\r\nHost: t\r\n\r\n", @buf[0]);
if !contains(r3, "Connection: close") { print("case3: expected close at cap\n"); return 1; }
drained := false;
tries := 0;
while !drained and tries < 200 {
srv.tick(5) catch {};
zq, ze := socket.read_nb(c1, @buf[0], 64);
if ze == error.Closed { drained = true; }
if !ze and zq == 0 { drained = true; }
tries += 1;
}
if !drained { print("case3: server did not close at the cap\n"); return 1; }
socket.close(c1);
print("request cap: close + EOF\n");
// ── 4. POST body echo ─────────────────────────────────────────────
c2 := dial();
if c2 < 0 { print("dial2 failed\n"); return 1; }
r4 := roundtrip(@srv, c2, "POST /echo HTTP/1.1\r\nHost: t\r\nContent-Length: 9\r\n\r\nping-pong", @buf[0]);
if !contains(r4, "ping-pong") { print("case4: body not echoed\n"); return 1; }
print("POST echo ok\n");
// ── 5. unknown path routes 404 ────────────────────────────────────
r5 := roundtrip(@srv, c2, "GET /missing HTTP/1.1\r\nHost: t\r\n\r\n", @buf[0]);
if !contains(r5, "HTTP/1.1 404 Not Found") { print("case5: expected 404\n"); return 1; }
socket.close(c2);
print("404 routing ok\n");
// ── 6. half a header is evicted at the request deadline, while a
// healthy client keeps being served ──────────────────────────
c3 := dial();
if c3 < 0 { print("dial3 failed\n"); return 1; }
half := "GET /hel";
socket.write(c3, half.ptr, xx half.len);
gone := event.deadline_in(300); // > timeout_request_ms
while !event.expired(gone) { srv.tick(5) catch {}; }
c4 := dial();
if c4 < 0 { print("dial4 failed\n"); return 1; }
r6 := roundtrip(@srv, c4, "GET /hello HTTP/1.1\r\nHost: t\r\n\r\n", @buf[0]);
if !contains(r6, "hello GET") { print("case6: healthy client starved\n"); return 1; }
evicted := false;
tries = 0;
while !evicted and tries < 200 {
srv.tick(5) catch {};
zq2, ze2 := socket.read_nb(c3, @buf[0], 64);
if ze2 == error.Closed { evicted = true; }
if !ze2 and zq2 == 0 { evicted = true; }
tries += 1;
}
if !evicted { print("case6: half-header connection never evicted\n"); return 1; }
socket.close(c3);
socket.close(c4);
print("slow client evicted, healthy client served\n");
srv.close();
print("http server ok\n");
return 0;
}