distd serves through std.http — the readiness loop lands (PLAN-HTTPZ A1)
The hand-rolled sequential accept loop, its SO_RCVTIMEO band-aid, and the whole src/server/http.sx module are retired: distd is now a std.http handler. Server.init gets the store directory through the ctx word; route() fills a Response instead of writing to a socket; every handler ports mechanically (respond_error/load_or_503/respond_render take *Response; bodies allocate from the per-request arena, never the stack, since serialization happens after the handler returns). Downloads keep X-Checksum-SHA256 via extra_headers; auth takes the extracted Authorization value; the 411 contract (POST/PUT must declare Content-Length) moves into the handler, pinned as before. Config: 512 MiB read cap (whole-body artifact uploads), 120s request deadline, 5s keepalive, 200 requests per connection. Idle connections now cost nothing — timeouts evict, never block. http_client gains its own 10s read timeout (the old shared helper's secs->ms change had silently shrunk it to 10ms). tests/server_http.sx pins the architecture: a request answers within 1s while SIX idle preconnects are held open (the retired loop paid 250ms-2s per idle socket serially), and two requests ride one keep-alive connection. make test 24/24 green.
This commit is contained in:
@@ -18,7 +18,20 @@
|
||||
|
||||
#import "modules/std.sx";
|
||||
sock :: #import "modules/std/socket.sx";
|
||||
hs :: #import "../server/http.sx";
|
||||
|
||||
// Bound blocking reads to `ms` milliseconds so a dead server fails the
|
||||
// publish instead of hanging it (10s: LAN responses arrive in
|
||||
// milliseconds; a large publish response can still take a moment).
|
||||
HC_SO_RCVTIMEO :i32: 0x1006; // macOS
|
||||
HcTimeval :: struct {
|
||||
tv_sec: i64;
|
||||
tv_usec: i32 = 0;
|
||||
pad: i32 = 0;
|
||||
}
|
||||
hc_set_read_timeout :: (fd: i32, ms: i64) {
|
||||
tv : HcTimeval = .{ tv_sec = ms / 1000, tv_usec = xx ((ms % 1000) * 1000) };
|
||||
sock.setsockopt(fd, sock.SOL_SOCKET, HC_SO_RCVTIMEO, xx @tv, 16);
|
||||
}
|
||||
|
||||
netc :: #library "c";
|
||||
c_connect :: (fd: i32, addr: *sock.SockAddr, addrlen: u32) -> i32 #foreign netc "connect";
|
||||
@@ -136,7 +149,7 @@ http_post :: (srv: ServerUrl, path: string, bearer: string, content_type: string
|
||||
sin_port = sock.htons(srv.port), sin_addr = srv.addr,
|
||||
};
|
||||
if c_connect(fd, @addr, 16) < 0 { sock.close(fd); raise error.Connect; }
|
||||
hs.set_read_timeout(fd, 10);
|
||||
hc_set_read_timeout(fd, 10000);
|
||||
|
||||
h := concat("POST ", concat(path, " HTTP/1.1\r\n"));
|
||||
h = concat(h, concat("Host: ", concat(srv.host, "\r\n")));
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
#import "../domain/audit.sx";
|
||||
#import "../domain/validate.sx";
|
||||
#import "../repo/repo.sx";
|
||||
http :: #import "http.sx";
|
||||
db :: #import "../repo/db.sx";
|
||||
jout :: #import "../json_out.sx";
|
||||
pl :: #import "../publish/publish.sx";
|
||||
@@ -149,33 +148,38 @@ adm_has_str :: (l: *List(string), s: string) -> bool {
|
||||
|
||||
// ── responses ─────────────────────────────────────────────────────────
|
||||
|
||||
adm_error :: (client: i32, code: i64, fail_code: string, fail_message: string) {
|
||||
adm_error :: (resp: *http.Response, code: i64, fail_code: string, fail_message: string) {
|
||||
f : jout.CliFailure = .{ code = fail_code, message = fail_message };
|
||||
raw : [4096]u8 = ---;
|
||||
// Allocated, not stack: the server serializes after the handler
|
||||
// returns, so the body must outlive this frame.
|
||||
raw : [*]u8 = xx context.allocator.alloc_bytes(4096);
|
||||
werr := false;
|
||||
n := jout.write_error(f, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
|
||||
n := jout.write_error(f, string.{ ptr = raw, len = 4096 }) catch { werr = true; 0 };
|
||||
body := "{\"status\":\"error\"}";
|
||||
if !werr { body = string.{ ptr = @raw[0], len = n }; }
|
||||
http.respond(client, code, "application/json", "", body);
|
||||
if !werr { body = string.{ ptr = raw, len = n }; }
|
||||
resp.status = code;
|
||||
resp.content_type = "application/json";
|
||||
resp.body = body;
|
||||
}
|
||||
|
||||
adm_load :: (client: i32, store_dir: string) -> ?Repo {
|
||||
adm_load :: (resp: *http.Response, store_dir: string) -> ?Repo {
|
||||
if !db.store_exists(store_dir) {
|
||||
adm_error(client, 503, "store.load",
|
||||
adm_error(resp, 503, "store.load",
|
||||
concat("no store database (nothing published yet): ", store_dir));
|
||||
return null;
|
||||
}
|
||||
loaded, le := db.load(store_dir);
|
||||
if le {
|
||||
adm_error(client, 503, "store.load",
|
||||
adm_error(resp, 503, "store.load",
|
||||
concat("the store database could not be loaded: ", store_dir));
|
||||
return null;
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
adm_html :: (client: i32, page: string) {
|
||||
http.respond(client, 200, "text/html; charset=utf-8", "", page);
|
||||
adm_html :: (resp: *http.Response, page: string) {
|
||||
resp.content_type = "text/html; charset=utf-8";
|
||||
resp.body = page;
|
||||
}
|
||||
|
||||
// ── chrome ────────────────────────────────────────────────────────────
|
||||
@@ -512,44 +516,44 @@ render_admin_audit :: (repo: *Repo) -> string {
|
||||
|
||||
// ── routing (distd delegates every /admin path here) ─────────────────
|
||||
|
||||
handle_admin :: (client: i32, store_dir: string, path: string) {
|
||||
rq := adm_load(client, store_dir);
|
||||
handle_admin :: (store_dir: string, path: string, resp: *http.Response) {
|
||||
rq := adm_load(resp, store_dir);
|
||||
if rq == null { return; }
|
||||
repo := rq!;
|
||||
|
||||
if path == "/admin" or path == "/admin/" {
|
||||
adm_html(client, render_admin_apps(@repo));
|
||||
adm_html(resp, render_admin_apps(@repo));
|
||||
return;
|
||||
}
|
||||
if path == "/admin/tokens" {
|
||||
adm_html(client, render_admin_tokens(@repo));
|
||||
adm_html(resp, render_admin_tokens(@repo));
|
||||
return;
|
||||
}
|
||||
if path == "/admin/audit" {
|
||||
adm_html(client, render_admin_audit(@repo));
|
||||
adm_html(resp, render_admin_audit(@repo));
|
||||
return;
|
||||
}
|
||||
if adm_starts_with(path, "/admin/apps/") {
|
||||
slug := adm_tail(path, "/admin/apps/");
|
||||
aq := repo.find_app_by_slug(slug);
|
||||
if aq == null {
|
||||
adm_error(client, 404, "admin.unknown_app",
|
||||
adm_error(resp, 404, "admin.unknown_app",
|
||||
concat("no app with that slug in the store: ", slug));
|
||||
return;
|
||||
}
|
||||
adm_html(client, render_admin_app(@repo, aq!));
|
||||
adm_html(resp, render_admin_app(@repo, aq!));
|
||||
return;
|
||||
}
|
||||
if adm_starts_with(path, "/admin/releases/") {
|
||||
id := adm_tail(path, "/admin/releases/");
|
||||
relq := repo.get_release(id);
|
||||
if relq == null {
|
||||
adm_error(client, 404, "admin.unknown_release",
|
||||
adm_error(resp, 404, "admin.unknown_release",
|
||||
concat("no release with that id in the store: ", id));
|
||||
return;
|
||||
}
|
||||
adm_html(client, render_admin_release(@repo, relq!));
|
||||
adm_html(resp, render_admin_release(@repo, relq!));
|
||||
return;
|
||||
}
|
||||
adm_error(client, 404, "http.not_found", concat("no admin route for ", path));
|
||||
adm_error(resp, 404, "http.not_found", concat("no admin route for ", path));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// auth.sx — bearer-token authentication for distd's write surface
|
||||
// (subplan 04, Slice 2).
|
||||
//
|
||||
// `authenticate` takes the raw header block plus the (scope, app, channel)
|
||||
// `authenticate` takes the Authorization header VALUE plus the (scope, app, channel)
|
||||
// the operation demands, and either returns the live Token or refuses:
|
||||
//
|
||||
// 401 Unauthorized — no `Authorization` header, a non-Bearer scheme, or
|
||||
@@ -36,7 +36,6 @@ db :: #import "../repo/db.sx";
|
||||
jout :: #import "../json_out.sx";
|
||||
pl :: #import "../publish/publish.sx";
|
||||
tops :: #import "../token/ops.sx";
|
||||
http :: #import "http.sx";
|
||||
|
||||
AuthErr :: error {
|
||||
Unauthorized, // -> 401
|
||||
@@ -79,14 +78,13 @@ check_message :: (e: TokenCheckErr, scope: string, app_slug: string, channel: st
|
||||
// Authenticate the request these headers came with, demanding `scope`
|
||||
// against (`app_slug`, `channel`) — "" where the operation has no app or
|
||||
// channel to constrain.
|
||||
authenticate :: (store_dir: string, headers: string, scope: string, app_slug: string, channel: string, fail_out: *jout.CliFailure) -> (Token, !AuthErr) {
|
||||
hq := http.header_value(headers, "authorization");
|
||||
if hq == null {
|
||||
authenticate :: (store_dir: string, auth_header: string, scope: string, app_slug: string, channel: string, fail_out: *jout.CliFailure) -> (Token, !AuthErr) {
|
||||
if auth_header.len == 0 {
|
||||
fail_out.code = "auth.missing";
|
||||
fail_out.message = "this route requires Authorization: Bearer <token>";
|
||||
raise error.Unauthorized;
|
||||
}
|
||||
sq := bearer_secret(hq!);
|
||||
sq := bearer_secret(auth_header);
|
||||
if sq == null {
|
||||
fail_out.code = "auth.malformed";
|
||||
fail_out.message = "Authorization header is not a Bearer credential";
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
#import "../validation/artifact_file.sx";
|
||||
#import "../publish/publish.sx";
|
||||
sock :: #import "modules/std/socket.sx";
|
||||
http :: #import "http.sx";
|
||||
au :: #import "auth.sx";
|
||||
db :: #import "../repo/db.sx";
|
||||
jout :: #import "../json_out.sx";
|
||||
@@ -126,29 +125,34 @@ render_buf :: () -> string {
|
||||
// ── responses ─────────────────────────────────────────────────────────
|
||||
|
||||
// JSON error body in the CLI's error shape, sent with the HTTP status.
|
||||
respond_error :: (client: i32, code: i64, fail_code: string, fail_message: string) {
|
||||
respond_error :: (resp: *http.Response, code: i64, fail_code: string, fail_message: string) {
|
||||
f : jout.CliFailure = .{ code = fail_code, message = fail_message };
|
||||
raw : [4096]u8 = ---;
|
||||
// The body must outlive this frame (the server serializes after the
|
||||
// handler returns), so the render buffer comes from the per-request
|
||||
// arena, never the stack.
|
||||
raw : [*]u8 = xx context.allocator.alloc_bytes(4096);
|
||||
werr := false;
|
||||
n := jout.write_error(f, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
|
||||
n := jout.write_error(f, string.{ ptr = raw, len = 4096 }) catch { werr = true; 0 };
|
||||
body := "{\"status\":\"error\"}";
|
||||
if !werr { body = string.{ ptr = @raw[0], len = n }; }
|
||||
http.respond(client, code, "application/json", "", body);
|
||||
if !werr { body = string.{ ptr = raw, len = n }; }
|
||||
resp.status = code;
|
||||
resp.content_type = "application/json";
|
||||
resp.body = body;
|
||||
}
|
||||
|
||||
// ── /api renders (builders own the `try`, callers catch) ─────────────
|
||||
|
||||
// Reload the persisted model. Null means the store has no readable
|
||||
// database — the 503 error response has already been sent.
|
||||
load_or_503 :: (client: i32, store_dir: string) -> ?Repo {
|
||||
load_or_503 :: (resp: *http.Response, store_dir: string) -> ?Repo {
|
||||
if !db.store_exists(store_dir) {
|
||||
respond_error(client, 503, "store.load",
|
||||
respond_error(resp, 503, "store.load",
|
||||
concat("no store database (nothing published yet): ", store_dir));
|
||||
return null;
|
||||
}
|
||||
loaded, le := db.load(store_dir);
|
||||
if le {
|
||||
respond_error(client, 503, "store.load",
|
||||
respond_error(resp, 503, "store.load",
|
||||
concat("the store database could not be loaded: ", store_dir));
|
||||
return null;
|
||||
}
|
||||
@@ -202,13 +206,15 @@ render_app_detail_json :: (repo: *Repo, app: App, dst: []u8) -> (i64, !JsonError
|
||||
|
||||
// Send `n` rendered bytes of `buf` as 200 JSON — or the overflow error
|
||||
// when the render didn't fit RENDER_CAP.
|
||||
respond_render :: (client: i32, buf: string, n: i64, overflowed: bool) {
|
||||
respond_render :: (resp: *http.Response, buf: string, n: i64, overflowed: bool) {
|
||||
if overflowed {
|
||||
respond_error(client, 500, "http.response_overflow",
|
||||
respond_error(resp, 500, "http.response_overflow",
|
||||
"response exceeded the server's render buffer");
|
||||
return;
|
||||
}
|
||||
http.respond(client, 200, "application/json", "", string.{ ptr = buf.ptr, len = n });
|
||||
resp.status = 200;
|
||||
resp.content_type = "application/json";
|
||||
resp.body = string.{ ptr = buf.ptr, len = n };
|
||||
}
|
||||
|
||||
// ── HTML index (the install-page seed, subplan 04 Slice 5) ───────────
|
||||
@@ -302,34 +308,35 @@ render_index :: (repo: *Repo) -> string {
|
||||
return concat(page, INDEX_FOOT);
|
||||
}
|
||||
|
||||
handle_index :: (client: i32, store_dir: string) {
|
||||
rq := load_or_503(client, store_dir);
|
||||
handle_index :: (resp: *http.Response, store_dir: string) {
|
||||
rq := load_or_503(resp, store_dir);
|
||||
if rq == null { return; }
|
||||
repo := rq!;
|
||||
http.respond(client, 200, "text/html; charset=utf-8", "", render_index(@repo));
|
||||
resp.content_type = "text/html; charset=utf-8";
|
||||
resp.body = render_index(@repo);
|
||||
}
|
||||
|
||||
// ── routes ────────────────────────────────────────────────────────────
|
||||
|
||||
handle_apps_index :: (client: i32, store_dir: string) {
|
||||
rq := load_or_503(client, store_dir);
|
||||
handle_apps_index :: (resp: *http.Response, store_dir: string) {
|
||||
rq := load_or_503(resp, store_dir);
|
||||
if rq == null { return; }
|
||||
repo := rq!;
|
||||
|
||||
buf := render_buf();
|
||||
werr := false;
|
||||
n := render_apps_json(@repo, buf) catch { werr = true; 0 };
|
||||
respond_render(client, buf, n, werr);
|
||||
respond_render(resp, buf, n, werr);
|
||||
}
|
||||
|
||||
handle_app_detail :: (client: i32, store_dir: string, slug: string) {
|
||||
rq := load_or_503(client, store_dir);
|
||||
handle_app_detail :: (resp: *http.Response, store_dir: string, slug: string) {
|
||||
rq := load_or_503(resp, store_dir);
|
||||
if rq == null { return; }
|
||||
repo := rq!;
|
||||
|
||||
aq := repo.find_app_by_slug(slug);
|
||||
if aq == null {
|
||||
respond_error(client, 404, "api.unknown_app",
|
||||
respond_error(resp, 404, "api.unknown_app",
|
||||
concat("no app with that slug in the store: ", slug));
|
||||
return;
|
||||
}
|
||||
@@ -337,24 +344,25 @@ handle_app_detail :: (client: i32, store_dir: string, slug: string) {
|
||||
buf := render_buf();
|
||||
werr := false;
|
||||
n := render_app_detail_json(@repo, aq!, buf) catch { werr = true; 0 };
|
||||
respond_render(client, buf, n, werr);
|
||||
respond_render(resp, buf, n, werr);
|
||||
}
|
||||
|
||||
handle_download :: (client: i32, store_dir: string, sha: string) {
|
||||
handle_download :: (resp: *http.Response, store_dir: string, sha: string) {
|
||||
if !is_hex64(sha) {
|
||||
respond_error(client, 404, "download.bad_key",
|
||||
respond_error(resp, 404, "download.bad_key",
|
||||
"download key must be a 64-char lowercase-hex sha256");
|
||||
return;
|
||||
}
|
||||
opath := path_join(store_dir, concat("objects/", sha));
|
||||
bq := read_file(opath);
|
||||
if bq == null {
|
||||
respond_error(client, 404, "download.unknown_object",
|
||||
respond_error(resp, 404, "download.unknown_object",
|
||||
concat("no object with that digest in the store: ", sha));
|
||||
return;
|
||||
}
|
||||
extra := concat("X-Checksum-SHA256: ", concat(sha, "\r\n"));
|
||||
http.respond(client, 200, "application/octet-stream", extra, bq!);
|
||||
resp.content_type = "application/octet-stream";
|
||||
resp.extra_headers = concat("X-Checksum-SHA256: ", concat(sha, "\r\n"));
|
||||
resp.body = bq!;
|
||||
}
|
||||
|
||||
// ── install pages (subplan 04 Slice 5) ────────────────────────────────
|
||||
@@ -408,23 +416,23 @@ InstallCtx :: struct {
|
||||
host: string = "localhost";
|
||||
}
|
||||
|
||||
resolve_install :: (client: i32, repo: *Repo, slug: string, chan_name: string) -> ?InstallCtx {
|
||||
resolve_install :: (resp: *http.Response, repo: *Repo, slug: string, chan_name: string) -> ?InstallCtx {
|
||||
aq := repo.find_app_by_slug(slug);
|
||||
if aq == null {
|
||||
respond_error(client, 404, "install.unknown_app",
|
||||
respond_error(resp, 404, "install.unknown_app",
|
||||
concat("no app with that slug in the store: ", slug));
|
||||
return null;
|
||||
}
|
||||
app := aq!;
|
||||
cq := repo.get_channel(app.id, chan_name);
|
||||
if cq == null {
|
||||
respond_error(client, 404, "install.unknown_channel",
|
||||
respond_error(resp, 404, "install.unknown_channel",
|
||||
concat("the app has no channel with that name: ", chan_name));
|
||||
return null;
|
||||
}
|
||||
rq := repo.get_release(cq!.current_release_id);
|
||||
if rq == null {
|
||||
respond_error(client, 404, "install.no_release",
|
||||
respond_error(resp, 404, "install.no_release",
|
||||
concat("the channel does not point at a published release: ", chan_name));
|
||||
return null;
|
||||
}
|
||||
@@ -528,43 +536,41 @@ render_install :: (ctx: *InstallCtx, repo: *Repo, detected: ?Platform) -> string
|
||||
return concat(page, "</body></html>");
|
||||
}
|
||||
|
||||
handle_install_page :: (client: i32, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
|
||||
rq := load_or_503(client, store_dir);
|
||||
handle_install_page :: (resp: *http.Response, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
|
||||
rq := load_or_503(resp, store_dir);
|
||||
if rq == null { return; }
|
||||
repo := rq!;
|
||||
ctxq := resolve_install(client, @repo, slug, chan_name);
|
||||
ctxq := resolve_install(resp, @repo, slug, chan_name);
|
||||
if ctxq == null { return; }
|
||||
ctx := ctxq!;
|
||||
|
||||
ua := "";
|
||||
uq := http.header_value(req.headers, "user-agent");
|
||||
if uq != null { ua = uq!; }
|
||||
hostq := http.header_value(req.headers, "host");
|
||||
if hostq != null { ctx.host = hostq!; }
|
||||
ua := http.find_header(req, "user-agent");
|
||||
hostv := http.find_header(req, "host");
|
||||
if hostv.len > 0 { ctx.host = hostv; }
|
||||
|
||||
http.respond(client, 200, "text/html; charset=utf-8", "",
|
||||
render_install(@ctx, @repo, ua_platform(ua)));
|
||||
resp.content_type = "text/html; charset=utf-8";
|
||||
resp.body = render_install(@ctx, @repo, ua_platform(ua));
|
||||
}
|
||||
|
||||
// The enterprise OTA manifest: Apple's plist shape, with the software
|
||||
// package URL pointing back at this server over HTTPS (itms-services
|
||||
// refuses plain http; TLS terminates at the reverse proxy).
|
||||
handle_manifest_plist :: (client: i32, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
|
||||
rq := load_or_503(client, store_dir);
|
||||
handle_manifest_plist :: (resp: *http.Response, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
|
||||
rq := load_or_503(resp, store_dir);
|
||||
if rq == null { return; }
|
||||
repo := rq!;
|
||||
ctxq := resolve_install(client, @repo, slug, chan_name);
|
||||
ctxq := resolve_install(resp, @repo, slug, chan_name);
|
||||
if ctxq == null { return; }
|
||||
ctx := ctxq!;
|
||||
|
||||
if ctx.app.ios_mode != .enterprise {
|
||||
respond_error(client, 404, "install.not_enterprise",
|
||||
respond_error(resp, 404, "install.not_enterprise",
|
||||
"the app's iOS install mode is not enterprise; no OTA manifest exists");
|
||||
return;
|
||||
}
|
||||
bid := bundle_id_for(ctx.app, .ios);
|
||||
if bid.len == 0 {
|
||||
respond_error(client, 404, "install.no_bundle_id",
|
||||
respond_error(resp, 404, "install.no_bundle_id",
|
||||
"the app has no iOS bundle id; set one with: dist app set --ios-bundle-id");
|
||||
return;
|
||||
}
|
||||
@@ -578,14 +584,14 @@ handle_manifest_plist :: (client: i32, store_dir: string, req: *http.Request, sl
|
||||
ai += 1;
|
||||
}
|
||||
if !found {
|
||||
respond_error(client, 404, "install.no_ios_artifact",
|
||||
respond_error(resp, 404, "install.no_ios_artifact",
|
||||
"the channel's current release carries no iOS artifact");
|
||||
return;
|
||||
}
|
||||
|
||||
host := "localhost";
|
||||
hostq := http.header_value(req.headers, "host");
|
||||
if hostq != null { host = hostq!; }
|
||||
hostv2 := http.find_header(req, "host");
|
||||
if hostv2.len > 0 { host = hostv2; }
|
||||
url := concat("https://", concat(host, concat("/download/", art.sha256)));
|
||||
|
||||
x := "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\"><dict><key>items</key><array><dict>";
|
||||
@@ -599,27 +605,28 @@ handle_manifest_plist :: (client: i32, store_dir: string, req: *http.Request, sl
|
||||
x = concat(x, html_escape(ctx.app.display_name));
|
||||
x = concat(x, "</string></dict></dict></array></dict></plist>\n");
|
||||
|
||||
http.respond(client, 200, "application/xml", "", x);
|
||||
resp.content_type = "application/xml";
|
||||
resp.body = x;
|
||||
}
|
||||
|
||||
// GET /install/<slug>/<channel>[/manifest.plist]
|
||||
handle_install_route :: (client: i32, store_dir: string, req: *http.Request, tail: string) {
|
||||
handle_install_route :: (resp: *http.Response, store_dir: string, req: *http.Request, tail: string) {
|
||||
s1 := seg_split(tail);
|
||||
if s1.head.len == 0 or s1.rest.len == 0 {
|
||||
respond_error(client, 404, "http.not_found",
|
||||
respond_error(resp, 404, "http.not_found",
|
||||
"install pages live at /install/<slug>/<channel>");
|
||||
return;
|
||||
}
|
||||
s2 := seg_split(s1.rest);
|
||||
if s2.rest.len == 0 {
|
||||
handle_install_page(client, store_dir, req, s1.head, s2.head);
|
||||
handle_install_page(resp, store_dir, req, s1.head, s2.head);
|
||||
return;
|
||||
}
|
||||
if s2.rest == "manifest.plist" {
|
||||
handle_manifest_plist(client, store_dir, req, s1.head, s2.head);
|
||||
handle_manifest_plist(resp, store_dir, req, s1.head, s2.head);
|
||||
return;
|
||||
}
|
||||
respond_error(client, 404, "http.not_found",
|
||||
respond_error(resp, 404, "http.not_found",
|
||||
concat("no install route for ", tail));
|
||||
}
|
||||
|
||||
@@ -648,28 +655,28 @@ seg_split :: (s: string) -> Seg {
|
||||
|
||||
// Authenticate a write request or answer it. Null means the refusal
|
||||
// response has already been sent.
|
||||
auth_or_respond :: (client: i32, store_dir: string, req: *http.Request, app_slug: string, channel: string) -> ?Token {
|
||||
auth_or_respond :: (resp: *http.Response, store_dir: string, req: *http.Request, app_slug: string, channel: string) -> ?Token {
|
||||
fail : jout.CliFailure = .{};
|
||||
t, ae := au.authenticate(store_dir, req.headers, "publish", app_slug, channel, @fail);
|
||||
t, ae := au.authenticate(store_dir, http.find_header(req, "authorization"), "publish", app_slug, channel, @fail);
|
||||
if ae {
|
||||
status : i64 = 401;
|
||||
if ae == error.Forbidden { status = 403; }
|
||||
if ae == error.Unavailable { status = 503; }
|
||||
respond_error(client, status, fail.code, fail.message);
|
||||
respond_error(resp, status, fail.code, fail.message);
|
||||
return null;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
// JSON-object body, or null after answering 400.
|
||||
body_object :: (client: i32, body: string) -> ?Object {
|
||||
body_object :: (resp: *http.Response, body: string) -> ?Object {
|
||||
v, pe := jsrv.parse(body, context.allocator);
|
||||
if pe {
|
||||
respond_error(client, 400, "api.bad_json", "request body is not valid JSON");
|
||||
respond_error(resp, 400, "api.bad_json", "request body is not valid JSON");
|
||||
return null;
|
||||
}
|
||||
if v != .object {
|
||||
respond_error(client, 400, "api.bad_json", "request body must be a JSON object");
|
||||
respond_error(resp, 400, "api.bad_json", "request body must be a JSON object");
|
||||
return null;
|
||||
}
|
||||
return v.object;
|
||||
@@ -685,7 +692,7 @@ wb_find :: (o: Object, key: string) -> ?Value {
|
||||
}
|
||||
|
||||
// Required string member, or null after answering 400.
|
||||
wb_req_str :: (client: i32, o: Object, key: string) -> ?string {
|
||||
wb_req_str :: (resp: *http.Response, o: Object, key: string) -> ?string {
|
||||
vq := wb_find(o, key);
|
||||
if vq != null {
|
||||
v := vq!;
|
||||
@@ -693,7 +700,7 @@ wb_req_str :: (client: i32, o: Object, key: string) -> ?string {
|
||||
if v.str.len > 0 { return v.str; }
|
||||
}
|
||||
}
|
||||
respond_error(client, 400, "api.missing_field",
|
||||
respond_error(resp, 400, "api.missing_field",
|
||||
concat("body requires a non-empty string member: ", key));
|
||||
return null;
|
||||
}
|
||||
@@ -717,10 +724,10 @@ wb_opt_int :: (o: Object, key: string, default_: i64) -> i64 {
|
||||
// POST /api/upload — content-address the raw body into the store. The
|
||||
// upload is app-agnostic (the bytes are inert until a release references
|
||||
// them), so auth demands only the publish scope.
|
||||
handle_upload :: (client: i32, store_dir: string, req: *http.Request) {
|
||||
if auth_or_respond(client, store_dir, req, "", "") == null { return; }
|
||||
handle_upload :: (resp: *http.Response, store_dir: string, req: *http.Request) {
|
||||
if auth_or_respond(resp, store_dir, req, "", "") == null { return; }
|
||||
if req.body.len == 0 {
|
||||
respond_error(client, 400, "upload.empty", "upload body is empty");
|
||||
respond_error(resp, 400, "upload.empty", "upload body is empty");
|
||||
return;
|
||||
}
|
||||
st := Store.init(store_dir);
|
||||
@@ -730,31 +737,31 @@ handle_upload :: (client: i32, store_dir: string, req: *http.Request) {
|
||||
if se { werr = true; }
|
||||
if !se { key = k; }
|
||||
if werr {
|
||||
respond_error(client, 500, "store.write",
|
||||
respond_error(resp, 500, "store.write",
|
||||
"upload bytes could not be content-addressed into the store");
|
||||
return;
|
||||
}
|
||||
body := concat("{\"status\":\"stored\",\"sha256\":\"", concat(key, concat("\",\"size_bytes\":", concat(int_to_string(req.body.len), "}"))));
|
||||
http.respond(client, 200, "application/json", "", body);
|
||||
resp.content_type = "application/json";
|
||||
resp.body = concat("{\"status\":\"stored\",\"sha256\":\"", concat(key, concat("\",\"size_bytes\":", concat(int_to_string(req.body.len), "}"))));
|
||||
}
|
||||
|
||||
// POST /api/apps/<slug>/releases — publish a release whose artifacts name
|
||||
// already-uploaded objects by digest. Mirrors the CLI manifest checks
|
||||
// (platform, digest, size, content type, filename extension) against the
|
||||
// STORE rather than local files, then commits through the shared pipeline.
|
||||
handle_release_create :: (client: i32, store_dir: string, req: *http.Request, slug: string) {
|
||||
oq := body_object(client, req.body);
|
||||
handle_release_create :: (resp: *http.Response, store_dir: string, req: *http.Request, slug: string) {
|
||||
oq := body_object(resp, req.body);
|
||||
if oq == null { return; }
|
||||
o := oq!;
|
||||
|
||||
versionq := wb_req_str(client, o, "version");
|
||||
versionq := wb_req_str(resp, o, "version");
|
||||
if versionq == null { return; }
|
||||
version := versionq!;
|
||||
channelq := wb_req_str(client, o, "channel");
|
||||
channelq := wb_req_str(resp, o, "channel");
|
||||
if channelq == null { return; }
|
||||
channel := channelq!;
|
||||
|
||||
tokq := auth_or_respond(client, store_dir, req, slug, channel);
|
||||
tokq := auth_or_respond(resp, store_dir, req, slug, channel);
|
||||
if tokq == null { return; }
|
||||
tok := tokq!;
|
||||
|
||||
@@ -768,7 +775,7 @@ handle_release_create :: (client: i32, store_dir: string, req: *http.Request, sl
|
||||
}
|
||||
}
|
||||
if !arts_ok {
|
||||
respond_error(client, 400, "api.missing_field",
|
||||
respond_error(resp, 400, "api.missing_field",
|
||||
"body requires a non-empty artifacts array");
|
||||
return;
|
||||
}
|
||||
@@ -778,25 +785,25 @@ handle_release_create :: (client: i32, store_dir: string, req: *http.Request, sl
|
||||
i := 0;
|
||||
while i < arts_arr.len {
|
||||
if arts_arr.items[i] != .object {
|
||||
respond_error(client, 400, "api.bad_json", "each artifact must be a JSON object");
|
||||
respond_error(resp, 400, "api.bad_json", "each artifact must be a JSON object");
|
||||
return;
|
||||
}
|
||||
ao := arts_arr.items[i].object;
|
||||
|
||||
pidq := wb_req_str(client, ao, "platform");
|
||||
pidq := wb_req_str(resp, ao, "platform");
|
||||
if pidq == null { return; }
|
||||
platform, perr := parse_platform(pidq!);
|
||||
if perr {
|
||||
respond_error(client, 400, "api.unknown_platform",
|
||||
respond_error(resp, 400, "api.unknown_platform",
|
||||
concat("unknown platform id: ", pidq!));
|
||||
return;
|
||||
}
|
||||
|
||||
shaq := wb_req_str(client, ao, "sha256");
|
||||
shaq := wb_req_str(resp, ao, "sha256");
|
||||
if shaq == null { return; }
|
||||
sha := shaq!;
|
||||
if !is_hex64(sha) {
|
||||
respond_error(client, 400, "api.bad_digest",
|
||||
respond_error(resp, 400, "api.bad_digest",
|
||||
"artifact sha256 must be a 64-char lowercase-hex digest");
|
||||
return;
|
||||
}
|
||||
@@ -806,14 +813,14 @@ handle_release_create :: (client: i32, store_dir: string, req: *http.Request, sl
|
||||
opath := path_join(store_dir, concat("objects/", sha));
|
||||
ob := read_file(opath);
|
||||
if ob == null {
|
||||
respond_error(client, 404, "api.unknown_object",
|
||||
respond_error(resp, 404, "api.unknown_object",
|
||||
concat("no object with that digest in the store (upload it first): ", sha));
|
||||
return;
|
||||
}
|
||||
actual_size := ob!.len;
|
||||
declared := wb_opt_int(ao, "size_bytes", -1);
|
||||
if declared >= 0 and declared != actual_size {
|
||||
respond_error(client, 400, "api.size_mismatch",
|
||||
respond_error(resp, 400, "api.size_mismatch",
|
||||
concat("declared size_bytes does not match the stored object: ", sha));
|
||||
return;
|
||||
}
|
||||
@@ -821,7 +828,7 @@ handle_release_create :: (client: i32, store_dir: string, req: *http.Request, sl
|
||||
ct := wb_opt_str(ao, "content_type");
|
||||
if ct.len == 0 { ct = pl.default_content_type(platform); }
|
||||
if !is_allowed_content_type(ct) {
|
||||
respond_error(client, 400, "api.content_type_denied",
|
||||
respond_error(resp, 400, "api.content_type_denied",
|
||||
concat("artifact content type is not on the allow-list: ", ct));
|
||||
return;
|
||||
}
|
||||
@@ -831,7 +838,7 @@ handle_release_create :: (client: i32, store_dir: string, req: *http.Request, sl
|
||||
fname = concat(slug, concat("-", concat(version, concat(".", expected_ext(platform)))));
|
||||
}
|
||||
if !ext_matches_platform(fname, platform) {
|
||||
respond_error(client, 400, "api.extension_mismatch",
|
||||
respond_error(resp, 400, "api.extension_mismatch",
|
||||
concat("artifact filename extension does not match its platform: ", fname));
|
||||
return;
|
||||
}
|
||||
@@ -857,14 +864,14 @@ handle_release_create :: (client: i32, store_dir: string, req: *http.Request, sl
|
||||
if ce == error.Persist {
|
||||
if fail.code == "persist.load" { status = 503; }
|
||||
}
|
||||
respond_error(client, status, fail.code, fail.message);
|
||||
respond_error(resp, status, fail.code, fail.message);
|
||||
return;
|
||||
}
|
||||
if !ce {
|
||||
buf := render_buf();
|
||||
werr := false;
|
||||
n := pl.write_json(@outcome, buf) catch { werr = true; 0 };
|
||||
respond_render(client, buf, n, werr);
|
||||
respond_render(resp, buf, n, werr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -878,71 +885,71 @@ op_http_status :: (e: ops.OpError) -> i64 {
|
||||
|
||||
// POST /api/apps/<slug>/channels/<name>/promote — body {"release_id":..};
|
||||
// delegates to the CLI's promote pipeline.
|
||||
handle_promote :: (client: i32, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
|
||||
oq := body_object(client, req.body);
|
||||
handle_promote :: (resp: *http.Response, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
|
||||
oq := body_object(resp, req.body);
|
||||
if oq == null { return; }
|
||||
relq := wb_req_str(client, oq!, "release_id");
|
||||
relq := wb_req_str(resp, oq!, "release_id");
|
||||
if relq == null { return; }
|
||||
|
||||
if auth_or_respond(client, store_dir, req, slug, chan_name) == null { return; }
|
||||
if auth_or_respond(resp, store_dir, req, slug, chan_name) == null { return; }
|
||||
|
||||
fail : jout.CliFailure = .{};
|
||||
o, e := ops.run_promote(store_dir, slug, chan_name, relq!, @fail);
|
||||
if e {
|
||||
respond_error(client, op_http_status(e), fail.code, fail.message);
|
||||
respond_error(resp, op_http_status(e), fail.code, fail.message);
|
||||
return;
|
||||
}
|
||||
if !e {
|
||||
buf := render_buf();
|
||||
werr := false;
|
||||
n := ops.write_promote_json(@o, buf) catch { werr = true; 0 };
|
||||
respond_render(client, buf, n, werr);
|
||||
respond_render(resp, buf, n, werr);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/apps/<slug>/channels/<name>/rollback — empty body; delegates
|
||||
// to the CLI's rollback pipeline.
|
||||
handle_rollback :: (client: i32, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
|
||||
if auth_or_respond(client, store_dir, req, slug, chan_name) == null { return; }
|
||||
handle_rollback :: (resp: *http.Response, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
|
||||
if auth_or_respond(resp, store_dir, req, slug, chan_name) == null { return; }
|
||||
|
||||
fail : jout.CliFailure = .{};
|
||||
o, e := ops.run_rollback(store_dir, slug, chan_name, @fail);
|
||||
if e {
|
||||
respond_error(client, op_http_status(e), fail.code, fail.message);
|
||||
respond_error(resp, op_http_status(e), fail.code, fail.message);
|
||||
return;
|
||||
}
|
||||
if !e {
|
||||
buf := render_buf();
|
||||
werr := false;
|
||||
n := ops.write_rollback_json(@o, buf) catch { werr = true; 0 };
|
||||
respond_render(client, buf, n, werr);
|
||||
respond_render(resp, buf, n, werr);
|
||||
}
|
||||
}
|
||||
|
||||
// Route a write request under POST /api/. Returns false when no write
|
||||
// route matches (the caller 404s).
|
||||
route_post :: (client: i32, store_dir: string, req: *http.Request) -> bool {
|
||||
route_post :: (resp: *http.Response, store_dir: string, req: *http.Request) -> bool {
|
||||
path := req.path;
|
||||
if path == "/api/upload" {
|
||||
handle_upload(client, store_dir, req);
|
||||
handle_upload(resp, store_dir, req);
|
||||
return true;
|
||||
}
|
||||
if starts_with(path, "/api/apps/") {
|
||||
s1 := seg_split(tail_after(path, "/api/apps/"));
|
||||
if s1.head.len == 0 { return false; }
|
||||
if s1.rest == "releases" {
|
||||
handle_release_create(client, store_dir, req, s1.head);
|
||||
handle_release_create(resp, store_dir, req, s1.head);
|
||||
return true;
|
||||
}
|
||||
if starts_with(s1.rest, "channels/") {
|
||||
s2 := seg_split(tail_after(s1.rest, "channels/"));
|
||||
if s2.head.len == 0 { return false; }
|
||||
if s2.rest == "promote" {
|
||||
handle_promote(client, store_dir, req, s1.head, s2.head);
|
||||
handle_promote(resp, store_dir, req, s1.head, s2.head);
|
||||
return true;
|
||||
}
|
||||
if s2.rest == "rollback" {
|
||||
handle_rollback(client, store_dir, req, s1.head, s2.head);
|
||||
handle_rollback(resp, store_dir, req, s1.head, s2.head);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -950,117 +957,103 @@ route_post :: (client: i32, store_dir: string, req: *http.Request) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Route one parsed request: GET reads, POST writes.
|
||||
route :: (client: i32, store_dir: string, req: *http.Request) -> i64 {
|
||||
// Route one parsed request into `resp`: GET reads, POST writes. The
|
||||
// outcome status lives in resp.status (respond_error sets it on every
|
||||
// refusal path).
|
||||
route :: (store_dir: string, req: *http.Request, resp: *http.Response) {
|
||||
method := req.method;
|
||||
path := req.path;
|
||||
if method == "GET" {
|
||||
if path == "/" {
|
||||
handle_index(client, store_dir);
|
||||
return 200;
|
||||
handle_index(resp, store_dir);
|
||||
return;
|
||||
}
|
||||
if path == "/healthz" {
|
||||
http.respond(client, 200, "application/json", "", "{\"status\":\"ok\"}");
|
||||
return 200;
|
||||
resp.content_type = "application/json";
|
||||
resp.body = "{\"status\":\"ok\"}";
|
||||
return;
|
||||
}
|
||||
if path == "/api/apps" {
|
||||
handle_apps_index(client, store_dir);
|
||||
return 200;
|
||||
handle_apps_index(resp, store_dir);
|
||||
return;
|
||||
}
|
||||
if starts_with(path, "/api/apps/") {
|
||||
handle_app_detail(client, store_dir, tail_after(path, "/api/apps/"));
|
||||
return 200;
|
||||
handle_app_detail(resp, store_dir, tail_after(path, "/api/apps/"));
|
||||
return;
|
||||
}
|
||||
if starts_with(path, "/download/") {
|
||||
handle_download(client, store_dir, tail_after(path, "/download/"));
|
||||
return 200;
|
||||
handle_download(resp, store_dir, tail_after(path, "/download/"));
|
||||
return;
|
||||
}
|
||||
if starts_with(path, "/install/") {
|
||||
handle_install_route(client, store_dir, req, tail_after(path, "/install/"));
|
||||
return 200;
|
||||
handle_install_route(resp, store_dir, req, tail_after(path, "/install/"));
|
||||
return;
|
||||
}
|
||||
if path == "/admin" or starts_with(path, "/admin/") {
|
||||
adm.handle_admin(client, store_dir, path);
|
||||
return 200;
|
||||
adm.handle_admin(store_dir, path, resp);
|
||||
return;
|
||||
}
|
||||
respond_error(client, 404, "http.not_found",
|
||||
respond_error(resp, 404, "http.not_found",
|
||||
concat("no route for ", path));
|
||||
return 404;
|
||||
}
|
||||
if method == "POST" {
|
||||
if route_post(client, store_dir, req) { return 200; }
|
||||
respond_error(client, 404, "http.not_found",
|
||||
concat("no write route for ", path));
|
||||
return 404;
|
||||
}
|
||||
respond_error(client, 405, "http.method_not_allowed",
|
||||
"distd routes are GET (reads) or POST (token-gated writes)");
|
||||
return 405;
|
||||
}
|
||||
|
||||
// ── server loop ───────────────────────────────────────────────────────
|
||||
|
||||
// Read one request off `client`, route it, log the result line. All
|
||||
// allocations land in the pushed per-request context allocator.
|
||||
serve_one :: (client: i32, store_dir: string) {
|
||||
req : http.Request = .{};
|
||||
closed := false;
|
||||
rstatus : i64 = 0;
|
||||
rcode := "";
|
||||
rmsg := "";
|
||||
http.read_request(client, @req) catch (e) {
|
||||
if e == error.Closed { closed = true; }
|
||||
if !closed {
|
||||
rstatus = 400; rcode = "http.bad_request";
|
||||
rmsg = "request could not be read as HTTP (bad request line or truncated body)";
|
||||
if e == error.LengthRequired {
|
||||
rstatus = 411; rcode = "http.length_required";
|
||||
rmsg = "POST/PUT requires a Content-Length header";
|
||||
}
|
||||
if e == error.BodyTooLarge {
|
||||
rstatus = 413; rcode = "http.body_too_large";
|
||||
rmsg = "request body exceeds the server's size cap";
|
||||
}
|
||||
if e == error.HeadersTooLarge {
|
||||
rstatus = 400; rcode = "http.headers_too_large";
|
||||
rmsg = "request header block exceeds 8K";
|
||||
}
|
||||
}
|
||||
};
|
||||
if closed { return; }
|
||||
if rstatus > 0 {
|
||||
respond_error(client, rstatus, rcode, rmsg);
|
||||
slog(concat("distd: unreadable request -> ", concat(int_to_string(rstatus), "\n")));
|
||||
return;
|
||||
}
|
||||
if method == "POST" {
|
||||
if route_post(resp, store_dir, req) { return; }
|
||||
respond_error(resp, 404, "http.not_found",
|
||||
concat("no write route for ", path));
|
||||
return;
|
||||
}
|
||||
respond_error(resp, 405, "http.method_not_allowed",
|
||||
"distd routes are GET (reads) or POST (token-gated writes)");
|
||||
}
|
||||
|
||||
code := route(client, store_dir, @req);
|
||||
line := concat("distd: ", concat(req.method, concat(" ", concat(req.path, concat(" -> ", concat(int_to_string(code), "\n"))))));
|
||||
// ── server loop (std.http, PLAN-HTTPZ A1) ─────────────────────────────
|
||||
|
||||
// The std.http handler: thread the store directory through the ctx
|
||||
// word, keep the 411 contract (POST/PUT must declare Content-Length —
|
||||
// std.http treats an absent header as a zero-length body), route, log.
|
||||
// Per-request allocations land in std.http's per-dispatch arena.
|
||||
DistdCtx :: struct {
|
||||
store_dir: string;
|
||||
}
|
||||
|
||||
distd_handle :: (req: *http.Request, resp: *http.Response, ctx: usize) {
|
||||
dctx : *DistdCtx = xx ctx;
|
||||
store_dir := dctx.store_dir;
|
||||
|
||||
refused := false;
|
||||
if req.method == "POST" or req.method == "PUT" {
|
||||
if http.find_header(req, "content-length").len == 0 {
|
||||
respond_error(resp, 411, "http.length_required",
|
||||
"POST/PUT requires a Content-Length header");
|
||||
refused = true;
|
||||
}
|
||||
}
|
||||
if !refused {
|
||||
route(store_dir, req, resp);
|
||||
}
|
||||
line := concat("distd: ", concat(req.method, concat(" ", concat(req.path, concat(" -> ", concat(int_to_string(resp.status), "\n"))))));
|
||||
slog(line);
|
||||
}
|
||||
|
||||
// Bind 0.0.0.0:<port> and serve forever (sequential, one connection at a
|
||||
// time — the deployment story is LAN/NAS-scale). Returns only when the
|
||||
// socket can't be opened. Per-request allocations live in an arena that
|
||||
// dies with the request.
|
||||
// Serve the store over std.http's readiness loop (PLAN-HTTPZ A1): idle
|
||||
// connections cost nothing, timeouts evict instead of blocking,
|
||||
// keep-alive holds between requests. Returns only when the socket
|
||||
// can't be opened.
|
||||
run_server :: (store_dir: string, port: i64) -> !ServeErr {
|
||||
fd, fe := http.listen_on(port);
|
||||
if fe { raise error.Bind; }
|
||||
dctx : DistdCtx = .{ store_dir = store_dir };
|
||||
cfg : http.Config = .{
|
||||
port = port,
|
||||
max_conn = 256,
|
||||
read_buf_cap = 536870912, // 512 MiB: artifact uploads arrive whole-body
|
||||
timeout_request_ms = 120000, // a large upload must complete within this
|
||||
timeout_keepalive_ms = 5000,
|
||||
request_count = 200,
|
||||
};
|
||||
srv, se := http.Server.init(cfg, distd_handle, xx @dctx);
|
||||
if se { raise error.Bind; }
|
||||
|
||||
slog(concat("distd: serving store ", concat(store_dir, concat(" on http://0.0.0.0:", concat(int_to_string(port), "\n")))));
|
||||
|
||||
while true {
|
||||
client := sock.accept(fd, null, null);
|
||||
if client < 0 { continue; }
|
||||
http.set_read_timeout(client, 250);
|
||||
|
||||
gpa := GPA.init();
|
||||
arena := Arena.init(xx gpa, 65536);
|
||||
push Context.{ allocator = xx arena } {
|
||||
serve_one(client, store_dir);
|
||||
}
|
||||
arena.deinit();
|
||||
sock.close(client);
|
||||
}
|
||||
srv.run();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
// =====================================================================
|
||||
// http.sx — minimal HTTP/1.1 over `std.socket` (subplan 04, Slices 1+2).
|
||||
//
|
||||
// The temporary in-repo boundary for the missing `std.http`, written so it
|
||||
// can be lifted into the sx stdlib later. Deliberately minimal — exactly
|
||||
// what a JSON API + artifact upload/download server needs:
|
||||
//
|
||||
// * `listen_on(port)` — a listening TCP socket on 0.0.0.0:<port>
|
||||
// (INADDR_ANY, so the server is reachable from the LAN);
|
||||
// * `read_request` — one full request: request line, header block, and a
|
||||
// Content-Length-bounded body, with typed failures for every refusal;
|
||||
// * `header_value` — case-insensitive header lookup;
|
||||
// * `respond` — one full response: status line, Content-Type/-Length,
|
||||
// `Connection: close`, optional extra header lines, body.
|
||||
//
|
||||
// REQUEST MEMORY: `read_request` allocates its header and body buffers
|
||||
// from `context.allocator` — the caller's per-request arena — so every
|
||||
// view in a `Request` lives exactly as long as the request being served.
|
||||
//
|
||||
// LIMITS: the header block must fit HDR_CAP (8K); a body must declare
|
||||
// Content-Length (411 otherwise) and fit MAX_BODY (413 otherwise). Bodies
|
||||
// are read whole into memory — streaming uploads are a later slice.
|
||||
//
|
||||
// NOT handled (v0, documented): keep-alive (every response closes),
|
||||
// chunked transfer, TLS (the deployment plan terminates TLS at a reverse
|
||||
// proxy).
|
||||
// =====================================================================
|
||||
|
||||
#import "modules/std.sx";
|
||||
sock :: #import "modules/std/socket.sx";
|
||||
|
||||
HttpError :: error {
|
||||
Socket,
|
||||
Bind,
|
||||
Listen,
|
||||
}
|
||||
|
||||
// Why a request could not be read. `Closed` = the peer sent nothing
|
||||
// (speculative preconnect or a dropped connection) — not worth a response.
|
||||
// Every other tag maps to a 4xx the caller sends.
|
||||
ReadErr :: error {
|
||||
Closed, // no bytes arrived (idle preconnect / disconnect)
|
||||
BadRequest, // request line unparseable, or the body never finished
|
||||
HeadersTooLarge, // header block exceeds HDR_CAP
|
||||
LengthRequired, // a method with a body arrived without Content-Length
|
||||
BodyTooLarge, // declared Content-Length exceeds MAX_BODY
|
||||
}
|
||||
|
||||
HDR_CAP :: 8192;
|
||||
MAX_BODY :: 536870912; // 512 MiB — bodies are held in memory whole
|
||||
|
||||
// One parsed request. All fields are views into per-request arena memory
|
||||
// owned by `read_request`.
|
||||
Request :: struct {
|
||||
method: string = "";
|
||||
path: string = "";
|
||||
headers: string = ""; // raw header block, between request line and the blank line
|
||||
body: string = "";
|
||||
}
|
||||
|
||||
// Open a listening socket on 0.0.0.0:<port>. SO_REUSEADDR so a restarted
|
||||
// server can re-bind without waiting out TIME_WAIT.
|
||||
listen_on :: (port: i64) -> (i32, !HttpError) {
|
||||
fd := sock.socket(sock.AF_INET, sock.SOCK_STREAM, 0);
|
||||
if fd < 0 { raise error.Socket; }
|
||||
opt : i32 = 1;
|
||||
sock.setsockopt(fd, sock.SOL_SOCKET, sock.SO_REUSEADDR, @opt, 4);
|
||||
addr : sock.SockAddr = .{ sin_len = 16, sin_family = 2, sin_port = sock.htons(port) };
|
||||
if sock.bind(fd, @addr, 16) < 0 { sock.close(fd); raise error.Bind; }
|
||||
if sock.listen(fd, 16) < 0 { sock.close(fd); raise error.Listen; }
|
||||
return fd;
|
||||
}
|
||||
|
||||
SO_RCVTIMEO :i32: 0x1006; // macOS
|
||||
|
||||
// macOS struct timeval (padded to 16 for the setsockopt copy).
|
||||
Timeval :: struct {
|
||||
tv_sec: i64;
|
||||
tv_usec: i32 = 0;
|
||||
pad: i32 = 0;
|
||||
}
|
||||
|
||||
// Bound blocking reads on `fd` to `ms` milliseconds. A sequential accept
|
||||
// loop needs this: browsers open speculative preconnections that never
|
||||
// send bytes, and an unbounded read on one of those wedges the whole
|
||||
// server while real requests sit in the backlog. Every idle socket costs
|
||||
// the full timeout serially, so it must stay well under human patience;
|
||||
// a LAN client delivers its request bytes within a few ms of connecting.
|
||||
// (Retired by PLAN-HTTPZ A1, when the loop goes readiness-based.)
|
||||
set_read_timeout :: (fd: i32, ms: i64) {
|
||||
tv : Timeval = .{ tv_sec = ms / 1000, tv_usec = xx ((ms % 1000) * 1000) };
|
||||
sock.setsockopt(fd, sock.SOL_SOCKET, SO_RCVTIMEO, xx @tv, 16);
|
||||
}
|
||||
|
||||
// Parse the request line `METHOD SP PATH SP HTTP/x.y` off the raw bytes.
|
||||
// False when the bytes don't look like an HTTP request line.
|
||||
parse_request :: (raw: string, req: *Request) -> bool {
|
||||
i := 0;
|
||||
while i < raw.len and raw[i] != 32 { i += 1; } // 32 = ' '
|
||||
if i == 0 or i >= raw.len { return false; }
|
||||
req.method = string.{ ptr = raw.ptr, len = i };
|
||||
|
||||
j := i + 1;
|
||||
k := j;
|
||||
while k < raw.len and raw[k] != 32 and raw[k] != 13 { k += 1; } // 13 = '\r'
|
||||
if k == j or k >= raw.len { return false; }
|
||||
if raw[k] != 32 { return false; } // no HTTP version after path
|
||||
req.path = string.{ ptr = @raw[j], len = k - j };
|
||||
return true;
|
||||
}
|
||||
|
||||
// Case-insensitive ASCII equality for header names.
|
||||
hname_eq :: (a: string, b: string) -> bool {
|
||||
if a.len != b.len { return false; }
|
||||
i := 0;
|
||||
while i < a.len {
|
||||
ca := a[i];
|
||||
cb := b[i];
|
||||
if ca >= 65 and ca <= 90 { ca += 32; } // 'A'..'Z' -> lower
|
||||
if cb >= 65 and cb <= 90 { cb += 32; }
|
||||
if ca != cb { return false; }
|
||||
i += 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// The value of header `name` (case-insensitive) in a raw header block —
|
||||
// leading spaces/tabs trimmed, null when absent.
|
||||
header_value :: (headers: string, name: string) -> ?string {
|
||||
i := 0;
|
||||
while i < headers.len {
|
||||
// the current line: [i, eol)
|
||||
eol := i;
|
||||
while eol < headers.len and headers[eol] != 13 { eol += 1; } // 13 = '\r'
|
||||
// split at ':'
|
||||
c := i;
|
||||
while c < eol and headers[c] != 58 { c += 1; } // 58 = ':'
|
||||
if c < eol {
|
||||
nm := string.{ ptr = @headers[i], len = c - i };
|
||||
if hname_eq(nm, name) {
|
||||
v := c + 1;
|
||||
while v < eol and (headers[v] == 32 or headers[v] == 9) { v += 1; }
|
||||
return string.{ ptr = @headers[v], len = eol - v };
|
||||
}
|
||||
}
|
||||
// skip "\r\n"
|
||||
i = eol + 2;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Non-negative decimal, or null (empty/garbage/overflow-length input).
|
||||
parse_content_length :: (s: string) -> ?i64 {
|
||||
if s.len == 0 or s.len > 12 { return null; }
|
||||
v : i64 = 0;
|
||||
i := 0;
|
||||
while i < s.len {
|
||||
c := s[i];
|
||||
if c < 48 or c > 57 { return null; } // '0'..'9'
|
||||
v = v * 10 + (c - 48);
|
||||
i += 1;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// Find "\r\n\r\n" in buf[0..len); -1 when absent.
|
||||
find_blank_line :: (buf: [*]u8, len: i64) -> i64 {
|
||||
i : i64 = 0;
|
||||
while i + 3 < len {
|
||||
if buf[i] == 13 and buf[i+1] == 10 and buf[i+2] == 13 and buf[i+3] == 10 {
|
||||
return i;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Read one full request off `client`: request line + headers (up to
|
||||
// HDR_CAP), then — when Content-Length says so — the whole body. GETs
|
||||
// carry no body; any request DECLARING a body gets it read regardless of
|
||||
// method, so the router can 405 with the connection drained.
|
||||
read_request :: (client: i32, req: *Request) -> !ReadErr {
|
||||
hbuf : [*]u8 = xx context.allocator.alloc_bytes(HDR_CAP);
|
||||
filled : i64 = 0;
|
||||
hdr_end : i64 = -1;
|
||||
|
||||
while hdr_end < 0 {
|
||||
if filled >= HDR_CAP { raise error.HeadersTooLarge; }
|
||||
n := sock.read(client, @hbuf[filled], xx (HDR_CAP - filled));
|
||||
if n <= 0 {
|
||||
if filled == 0 { raise error.Closed; }
|
||||
raise error.BadRequest;
|
||||
}
|
||||
filled += n;
|
||||
hdr_end = find_blank_line(hbuf, filled);
|
||||
}
|
||||
|
||||
head := string.{ ptr = hbuf, len = hdr_end };
|
||||
if !parse_request(head, req) { raise error.BadRequest; }
|
||||
|
||||
// header block = after the request line, before the blank line
|
||||
line_end := 0;
|
||||
while line_end < head.len and head[line_end] != 13 { line_end += 1; }
|
||||
hstart := line_end + 2;
|
||||
if hstart < hdr_end {
|
||||
req.headers = string.{ ptr = @hbuf[hstart], len = hdr_end - hstart };
|
||||
} else {
|
||||
req.headers = "";
|
||||
}
|
||||
|
||||
// body: Content-Length governs; absent means none expected — except
|
||||
// for POST/PUT, which must declare one (411) so an upload can never
|
||||
// be silently truncated.
|
||||
clq := header_value(req.headers, "content-length");
|
||||
if clq == null {
|
||||
if req.method == "POST" or req.method == "PUT" { raise error.LengthRequired; }
|
||||
req.body = "";
|
||||
return;
|
||||
}
|
||||
clv := parse_content_length(clq!);
|
||||
if clv == null { raise error.BadRequest; }
|
||||
body_len := clv!;
|
||||
if body_len == 0 { req.body = ""; return; }
|
||||
if body_len > MAX_BODY { raise error.BodyTooLarge; }
|
||||
|
||||
bbuf : [*]u8 = xx context.allocator.alloc_bytes(xx body_len);
|
||||
have : i64 = 0;
|
||||
|
||||
// bytes that arrived in the header read belong to the body
|
||||
spill := filled - (hdr_end + 4);
|
||||
if spill > 0 {
|
||||
take := if spill > body_len then body_len else spill;
|
||||
memcpy(bbuf, @hbuf[hdr_end + 4], xx take);
|
||||
have = take;
|
||||
}
|
||||
while have < body_len {
|
||||
n := sock.read(client, @bbuf[have], xx (body_len - have));
|
||||
if n <= 0 { raise error.BadRequest; } // peer quit mid-body
|
||||
have += n;
|
||||
}
|
||||
req.body = string.{ ptr = bbuf, len = body_len };
|
||||
return;
|
||||
}
|
||||
|
||||
status_text :: (code: i64) -> string {
|
||||
if code == 200 { return "OK"; }
|
||||
if code == 400 { return "Bad Request"; }
|
||||
if code == 401 { return "Unauthorized"; }
|
||||
if code == 403 { return "Forbidden"; }
|
||||
if code == 404 { return "Not Found"; }
|
||||
if code == 405 { return "Method Not Allowed"; }
|
||||
if code == 409 { return "Conflict"; }
|
||||
if code == 411 { return "Length Required"; }
|
||||
if code == 413 { return "Payload Too Large"; }
|
||||
if code == 503 { return "Service Unavailable"; }
|
||||
return "Internal Server Error";
|
||||
}
|
||||
|
||||
// Write one complete response and leave the connection for the caller to
|
||||
// close. `extra` is zero or more pre-formatted header lines, each ending
|
||||
// in `\r\n` ("" for none).
|
||||
respond :: (client: i32, code: i64, content_type: string, extra: string, body: string) {
|
||||
h := concat("HTTP/1.1 ", concat(int_to_string(code), concat(" ", status_text(code))));
|
||||
h = concat(h, "\r\n");
|
||||
h = concat(h, concat("Content-Type: ", concat(content_type, "\r\n")));
|
||||
h = concat(h, concat("Content-Length: ", concat(int_to_string(body.len), "\r\n")));
|
||||
h = concat(h, "Connection: close\r\n");
|
||||
h = concat(h, extra);
|
||||
h = concat(h, "\r\n");
|
||||
sock.write(client, h.ptr, xx h.len);
|
||||
if body.len > 0 { sock.write(client, body.ptr, xx body.len); }
|
||||
}
|
||||
@@ -183,17 +183,28 @@ main :: () -> i32 {
|
||||
process.assert(get_str(get_obj(bad, "error"), "code") == "download.unknown_object", "unknown digest names download.unknown_object");
|
||||
print(" download ok\n");
|
||||
|
||||
// ── idle preconnect must not wedge the accept loop ────────────────
|
||||
// Hold a connection open that never sends bytes (what a browser's
|
||||
// speculative preconnect does) and require a real request to still be
|
||||
// answered: the 2s read timeout must free the loop well inside curl's
|
||||
// 5s budget. Pre-fix (no SO_RCVTIMEO) this curl times out with 000.
|
||||
process.run("sh -c '(sleep 6 | nc 127.0.0.1 18792 > /dev/null 2>&1) &'");
|
||||
// ── idle preconnects cost nothing (PLAN-HTTPZ A1) ─────────────────
|
||||
// Hold SIX connections open that never send bytes (browser-style
|
||||
// speculative preconnects) and require a real request to answer
|
||||
// within 1s. The retired sequential loop paid its read timeout per
|
||||
// idle socket serially (6 x 250ms band-aid = 1.5s; 6 x 2s = 12s
|
||||
// before that), so this pins the readiness architecture, not a
|
||||
// tuned timeout.
|
||||
process.run("sh -c 'for i in 1 2 3 4 5 6; do (sleep 8 | nc 127.0.0.1 18792 > /dev/null 2>&1) & done'");
|
||||
process.run("sleep 0.3");
|
||||
wc := process.run(concat(concat("curl -s -m 5 -o /dev/null -w '%{http_code}' ", BASE), "/healthz"));
|
||||
wc := process.run(concat(concat("curl -s -m 1 -o /dev/null -w '%{http_code}' ", BASE), "/healthz"));
|
||||
process.assert(wc != null, "curl spawn failed (idle-conn case)");
|
||||
process.assert(wc!.stdout == "200", "request must be served while an idle connection is held open");
|
||||
print(" idle connection cannot wedge the loop\n");
|
||||
process.assert(wc!.stdout == "200", "request must answer within 1s while 6 idle connections are held open");
|
||||
print(" 6 idle preconnects: served within 1s\n");
|
||||
|
||||
// ── keep-alive: two requests ride one connection ──────────────────
|
||||
// curl reuses its connection for consecutive URLs; both responses
|
||||
// must arrive (the retired loop closed after every response).
|
||||
ka := process.run(concat(concat(concat(concat("curl -s -m 2 ", BASE), "/healthz "), BASE), "/healthz"));
|
||||
process.assert(ka != null, "curl spawn failed (keep-alive case)");
|
||||
process.assert(ka!.stdout == "{\"status\":\"ok\"}{\"status\":\"ok\"}",
|
||||
"two requests on one connection both answer");
|
||||
print(" keep-alive reuse ok\n");
|
||||
|
||||
// ── freshness: publish B while the server runs ────────────────────
|
||||
rb := process.run(publish_cmd(path_join(MDIR, "b.json")));
|
||||
|
||||
Reference in New Issue
Block a user