server_http gains the case that caught the live crash: 1000 keep-alive requests at c=10 against /api/apps (full SQLite load per request, on the 4-worker pool) must complete with 0 failures and leave the server answering. sqlite_api's threadsafe pins flip to guard the NEW invariant — a regression to THREADSAFE=0 reintroduces heap corruption under the pool (free-of-unallocated inside yy_reduce, caught under ab -c20).
236 lines
12 KiB
Plaintext
236 lines
12 KiB
Plaintext
// Pinned acceptance for P4.1 — `dist server run` serves the local store
|
|
// read-only over HTTP.
|
|
//
|
|
// Publishes the fixture manifest into a fresh store, starts the BUILT
|
|
// `build/dist server run` on a test port in the background, waits for
|
|
// /healthz, and asserts every route against curl:
|
|
//
|
|
// * / → HTML index naming the app, with a
|
|
// /download/<sha> link
|
|
// * /healthz → {"status":"ok"}
|
|
// * /api/apps → the published app is listed
|
|
// * /api/apps/<slug> → app + its releases + channels
|
|
// * /api/apps/<unknown> → JSON error api.unknown_app
|
|
// * /download/<sha256> → bytes identical to the source fixture
|
|
// * /download/<unknown sha> → JSON error download.unknown_object
|
|
// * any other path → JSON error http.not_found
|
|
//
|
|
// FRESHNESS is asserted by publishing a SECOND version while the server
|
|
// is running: the next /api/apps/<slug> must list both releases (the
|
|
// store database is reloaded per request — no stale cache).
|
|
#import "modules/std.sx";
|
|
#import "modules/std/json.sx";
|
|
process :: #import "modules/std/process.sx";
|
|
fs :: #import "modules/std/fs.sx";
|
|
sq :: #import "vendors/sqlite/sqlite.sx";
|
|
|
|
STORE :: ".sx-tmp/server_http";
|
|
MDIR :: ".sx-tmp/server_http_m";
|
|
PORT :: "18792";
|
|
BASE :: "http://127.0.0.1:18792";
|
|
|
|
MANIFEST_A :: "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}";
|
|
MANIFEST_B :: "{\"app\":\"acme-app\",\"version\":\"1.2.4\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}";
|
|
|
|
get :: (o: Object, key: string) -> Value {
|
|
i := 0;
|
|
while i < o.len {
|
|
if o.items[i].key == key { return o.items[i].val; }
|
|
i += 1;
|
|
}
|
|
process.assert(false, concat("missing json key: ", key));
|
|
dummy : Value = .null_;
|
|
return dummy;
|
|
}
|
|
get_str :: (o: Object, key: string) -> string { return get(o, key).str; }
|
|
get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; }
|
|
get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; }
|
|
|
|
write_file :: (path: string, body: string) {
|
|
cmd := concat(concat(concat("printf '%s' '", body), "' > "), path);
|
|
process.run(cmd);
|
|
}
|
|
|
|
// True iff `needle` occurs in `hay` (plain scan; bodies are small).
|
|
contains :: (hay: string, needle: string) -> bool {
|
|
if needle.len == 0 { return true; }
|
|
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;
|
|
}
|
|
|
|
// GET `path` and return the body (curl; 2s timeout so a dead server fails
|
|
// the test instead of hanging it).
|
|
fetch :: (path: string) -> string {
|
|
r := process.run(concat(concat("curl -s -m 2 ", BASE), path));
|
|
process.assert(r != null, concat("curl spawn failed: ", path));
|
|
return r!.stdout;
|
|
}
|
|
|
|
// GET `path` and return the HTTP status code as text.
|
|
fetch_code :: (path: string) -> string {
|
|
r := process.run(concat(concat("curl -s -m 2 -o /dev/null -w '%{http_code}' ", BASE), path));
|
|
process.assert(r != null, concat("curl spawn failed: ", path));
|
|
return r!.stdout;
|
|
}
|
|
|
|
// Parse a fetched JSON body (hard assert on parse failure).
|
|
parse_body :: (body: string, what: string, scratch: Allocator) -> Object {
|
|
v, e := parse(body, scratch);
|
|
if e {
|
|
process.assert(false, concat("response must be valid JSON: ", what));
|
|
dummy : Object = .{};
|
|
return dummy;
|
|
}
|
|
return v.object;
|
|
}
|
|
|
|
publish_cmd :: (mpath: string) -> string {
|
|
c := concat("build/dist ci publish --manifest ", mpath);
|
|
c = concat(c, concat(" --local-store ", STORE));
|
|
return concat(c, " --json 2>/dev/null >/dev/null");
|
|
}
|
|
|
|
main :: () -> i32 {
|
|
gpa := GPA.init();
|
|
arena := Arena.init(xx gpa, 1 << 20);
|
|
defer arena.deinit();
|
|
|
|
// Fresh state; clear any stale server from a crashed prior run.
|
|
process.run("pkill -f 'dist server run --local-store .sx-tmp/server_http' 2>/dev/null");
|
|
process.run(concat("rm -rf ", STORE));
|
|
process.run(concat("rm -rf ", MDIR));
|
|
process.run(concat("mkdir -p ", MDIR));
|
|
write_file(path_join(MDIR, "a.json"), MANIFEST_A);
|
|
write_file(path_join(MDIR, "b.json"), MANIFEST_B);
|
|
|
|
ra := process.run(publish_cmd(path_join(MDIR, "a.json")));
|
|
process.assert(ra != null and ra!.exit_code == 0, "publish A must exit 0");
|
|
|
|
// ── start the server, hold its pid, poll /healthz until ready ─────
|
|
sp := process.run(concat(concat(concat("sh -c 'build/dist server run --local-store ", STORE), concat(concat(" --port ", PORT), " >/dev/null 2>&1 & echo $!'")), ""));
|
|
process.assert(sp != null, "server spawn failed");
|
|
pid := sp!.stdout;
|
|
|
|
ready := false;
|
|
tries := 0;
|
|
while tries < 50 {
|
|
c := fetch_code("/healthz");
|
|
if c == "200" { ready = true; break; }
|
|
process.run("sleep 0.2");
|
|
tries += 1;
|
|
}
|
|
process.assert(ready, "server must answer /healthz within 10s");
|
|
print(" server up\n");
|
|
|
|
// ── routes ────────────────────────────────────────────────────────
|
|
process.assert(fetch_code("/") == "200", "/ serves the HTML index");
|
|
idx := fetch("/");
|
|
process.assert(contains(idx, "<title>dist</title>"), "index is the dist HTML page");
|
|
process.assert(contains(idx, "acme-app"), "index names the published app");
|
|
process.assert(contains(idx, "/download/"), "index links artifact downloads");
|
|
|
|
hz := parse_body(fetch("/healthz"), "/healthz", xx arena);
|
|
process.assert(get_str(hz, "status") == "ok", "/healthz status ok");
|
|
|
|
apps := parse_body(fetch("/api/apps"), "/api/apps", xx arena);
|
|
apps_arr := get_arr(apps, "apps");
|
|
process.assert(apps_arr.len == 1, "/api/apps lists one app");
|
|
process.assert(get_str(apps_arr.items[0].object, "slug") == "acme-app", "/api/apps lists acme-app");
|
|
|
|
det := parse_body(fetch("/api/apps/acme-app"), "/api/apps/acme-app", xx arena);
|
|
process.assert(get_str(get_obj(det, "app"), "slug") == "acme-app", "detail names the app");
|
|
process.assert(get_arr(det, "releases").len == 1, "detail lists one release");
|
|
process.assert(get_arr(det, "channels").len == 1, "detail lists one channel");
|
|
|
|
process.assert(fetch_code("/api/apps/nope") == "404", "unknown slug is 404");
|
|
nf := parse_body(fetch("/api/apps/nope"), "unknown slug body", xx arena);
|
|
process.assert(get_str(get_obj(nf, "error"), "code") == "api.unknown_app", "unknown slug names api.unknown_app");
|
|
|
|
process.assert(fetch_code("/bogus") == "404", "unknown route is 404");
|
|
nr := parse_body(fetch("/bogus"), "unknown route body", xx arena);
|
|
process.assert(get_str(get_obj(nr, "error"), "code") == "http.not_found", "unknown route names http.not_found");
|
|
print(" api routes ok\n");
|
|
|
|
// ── download: bytes identical to the source fixture ───────────────
|
|
// The published artifact's digest, read from the store database.
|
|
conn, oe := sq.Sqlite.open_v2(path_join(STORE, "dist.db"), sq.SQLITE_OPEN_READONLY);
|
|
process.assert(!oe, "dist.db must open as a SQLite database");
|
|
conn.busy_timeout(2000);
|
|
stq, pe := conn.prepare("SELECT sha256 FROM artifacts ORDER BY rowid");
|
|
process.assert(!pe, "artifact digest query must prepare");
|
|
src, se := stq.step();
|
|
process.assert(!se and src == sq.SQLITE_ROW, "store must record the artifact");
|
|
sha := stq.column_text(0);
|
|
stq.finalize();
|
|
conn.close();
|
|
|
|
dl := process.run(concat(concat(concat(concat("curl -s -m 2 -o .sx-tmp/server_http_dl.bin ", BASE), "/download/"), sha), " && cmp -s .sx-tmp/server_http_dl.bin examples/fixtures/acme-1.2.3-android.apk && echo SAME"));
|
|
process.assert(dl != null, "download curl spawn failed");
|
|
process.assert(dl!.stdout == "SAME\n", "downloaded bytes must equal the source fixture");
|
|
|
|
bad := parse_body(fetch("/download/0000000000000000000000000000000000000000000000000000000000000000"), "unknown object body", xx arena);
|
|
process.assert(get_str(get_obj(bad, "error"), "code") == "download.unknown_object", "unknown digest names download.unknown_object");
|
|
print(" download ok\n");
|
|
|
|
// ── 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 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 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");
|
|
|
|
// ── concurrent load must not corrupt anything (PLAN-HTTPZ A2) ────
|
|
// distd handlers run on a 4-worker pool; /api/apps does a full
|
|
// SQLite load per request. 1000 keep-alive requests at c=10 caught
|
|
// the SQLITE_THREADSAFE=0 heap corruption live — this pins the
|
|
// threadsafe build and the pooled dispatch path end to end.
|
|
abr := process.run(concat(concat("ab -q -n 1000 -c 10 -k ", BASE), "/api/apps 2>/dev/null | grep -c 'Failed requests: 0'"));
|
|
process.assert(abr != null and abr!.exit_code == 0, "ab spawn failed (concurrency case)");
|
|
process.assert(contains(abr!.stdout, "1"), "1000 concurrent requests complete with 0 failures");
|
|
alive := process.run(concat(concat("curl -s -m 2 -o /dev/null -w '%{http_code}' ", BASE), "/healthz"));
|
|
process.assert(alive != null and alive!.stdout == "200", "server alive after concurrent load");
|
|
print(" pooled handlers: 1000 concurrent requests, 0 failures, server alive\n");
|
|
|
|
// ── freshness: publish B while the server runs ────────────────────
|
|
rb := process.run(publish_cmd(path_join(MDIR, "b.json")));
|
|
process.assert(rb != null and rb!.exit_code == 0, "publish B must exit 0");
|
|
det2 := parse_body(fetch("/api/apps/acme-app"), "detail after B", xx arena);
|
|
process.assert(get_arr(det2, "releases").len == 2, "server reflects the new release without restart");
|
|
print(" per-request reload ok\n");
|
|
|
|
// ── teardown ──────────────────────────────────────────────────────
|
|
process.run(concat("kill ", pid));
|
|
process.run("rm -f .sx-tmp/server_http_dl.bin");
|
|
process.run(concat("rm -rf ", STORE));
|
|
process.run(concat("rm -rf ", MDIR));
|
|
print("server_http: ALL CASES PASS\n");
|
|
return 0;
|
|
}
|