feat(framework): v1 polish — helpers, 405+Allow, HEAD, Logging, set_header

- App.get/post/put/delete_(pattern, take h: Handler) — the take-interface
  shape probe-proven release + ASan before landing; retires plan-16
  deviation 1; delete_ because delete is the query keyword
- dispatch matches path-first: wrong method on a known path answers 405
  with Allow in registration order; unknown path stays 404
- HEAD routed as GET, body suppressed, Content-Length names the body a
  GET would carry (serialize gains head_only)
- Logging middleware (request line to stderr) ships in router/
- set_header(mut r, name, value) — the builder escape hatch
- web-app registers through the helpers (dogfood); README documents all
- gate grows 14 -> 16: 405+Allow, HEAD-vs-GET content-length equality
- all gates green: web-app 16/0, woc-test 540/0, oop-e2e 89/0,
  deps-accept 8/0, log-watcher 7/0, employee 8/0
- board/story: iteration 17 parked (spec+plan ready on library-internal),
  16 carries the v1-polish landing, order list updated

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-20 03:04:57 +02:00
parent b2da9b3d6d
commit 3d33b7bf58
11 changed files with 184 additions and 64 deletions

View file

@ -19,41 +19,25 @@ Statuses: ✅ **done** · 🔄 **in progress** · ⬜ **pending** · ⏸ **hold*
## ▶ NEXT PLAN ## ▶ NEXT PLAN
**The web framework becomes a first-class library — iteration 17.** The goal **Framework v1 — a polished micro-framework (routing, middleware,
shifted 2026-08-20: the previous NEXT PLAN ("make log-watcher executable — `Req`/`Resp`), nothing MVC-scale.** Directive 2026-08-20: iteration 17
nothing else") was met in full 2026-08-15 (milestone record below), and the (library kind + `internal/`) is **parked** — spec + plan approved and ready
driving workload is now `docs/examples/writeonce-framework` consumed by on branch `library-internal` — and the framework itself is the work. The
`docs/examples/web-app` through `[deps]`. Iteration 17's forks are settled v1-polish slice landed the same day (branch `framework-v1`): registration
(decisions + framework/compiler/VM/GC impact in helpers `get/post/put/delete_` (the take-Handler shape, probe-proven),
[the iteration](stories/language-runtime-database/17-library-projects-internal.md)); 405 + `Allow` on wrong-method hits, HEAD served as GET with the body
what remains, in order: suppressed, a `Logging` middleware, `set_header`; `just web-app` grew to
16/0. En route it exposed and fixed a real emitter bug: a Text-typed
single-segment interpolation of a place (`"${r.method}"`, `r` a loop
borrow) crossed `let`/assignment boundaries uncopied — aliased the field,
crashed the release build; `copy_place_text` now sees through `Interp`
exactly as `drop_fresh_text` does, pinned by
`tests/corpus/run/interp-borrowed-field`.
1. Merge the `web-framework` branch to master (iterations 15–17 docs + code, Next per the implementation order (17 parked): finish the half-done
local only). Why first: everything iteration 17 edits exists ONLY on that database branches — 9c (ipc-attach: manifest + binding) and 9d
branch — the framework and web-app sources, the `[deps]` resolution code (keypair-auth: manifest) — both need their forks brainstormed before
in `compiler/bin/main.ml` (17's `internal/` rule lands in that exact planning.
code), and the `just web-app` regression gate. Starting 17 unmerged means
stacking a branch on an unreviewed branch; merged, 17 is a small clean
diff off master.
2. Spec + plan from the settled decisions (prose only, per convention).
3. `kind = "library"` in `wo.toml` (default `"program"`): `woc <dir>` on a
library runs the FULL pipeline as a check — parse, typecheck, borrow
check, GC inference — with no entry required; an explicit build still
works when a `main` exists (dual lib+bin).
4. The `internal/` rule at the `[deps]` boundary: a consumer `use` of a dep
path containing the segment `internal` is a new WO-E1xx at the `use`,
naming the dependency; inside the dep it stays legal.
5. Framework reorg: request-parser and serve-loop plumbing move under
`internal/`; the public surface (`Handler`, `Middleware`, `App`,
`Req`/`Resp`, builders) does not move.
6. Gate: `just web-app` stays 14/0, plus new checks — library check without
an entry succeeds, a consumer `internal` import is refused, and the
iteration-16 `--emit` verification workaround is deleted.
The VM and GC are untouched by design — visibility is compile-time name
resolution, libraries compile whole-program into the consumer's image, and
GC inference stays whole-program (app usage may promote dep classes; that is
intended).
--- ---
@ -167,8 +151,8 @@ that sequences its tasks. Read one, approve, then the next starts.
| 13 | [Compile-time metaprogramming](stories/language-runtime-database/13-compile-time-metaprogramming.md) | ⬜ needs a spec first | | 13 | [Compile-time metaprogramming](stories/language-runtime-database/13-compile-time-metaprogramming.md) | ⬜ needs a spec first |
| 14 | [skillhost host workload](stories/language-runtime-database/14-skillhost-host-workload.md) | ⬜ gaps recorded (branch query-grammar found skillhost needs no new query grammar); each gap a candidate iteration | | 14 | [skillhost host workload](stories/language-runtime-database/14-skillhost-host-workload.md) | ⬜ gaps recorded (branch query-grammar found skillhost needs no new query grammar); each gap a candidate iteration |
| 15 | [deps: `wo.toml [deps]`](stories/language-runtime-database/15-deps-package-manager.md) | ✅ **landed 2026-08-18** (branch web-framework): [deps] inline tables, git-binary fetch, wo.lock pinning, offline-when-locked, --update-deps, WO-E106/E107; `just deps-accept` 8/0 | | 15 | [deps: `wo.toml [deps]`](stories/language-runtime-database/15-deps-package-manager.md) | ✅ **landed 2026-08-18** (branch web-framework): [deps] inline tables, git-binary fetch, wo.lock pinning, offline-when-locked, --update-deps, WO-E106/E107; `just deps-accept` 8/0 |
| 16 | [web framework](stories/language-runtime-database/16-web-framework.md) | ✅ **landed 2026-08-19** — writeonce-framework (HTTP/1.1 + router + Handler/Middleware) consumed by web-app through [deps]; `just web-app` 14/0; h2c parked (§C) behind 8/9f/11 | | 16 | [web framework](stories/language-runtime-database/16-web-framework.md) | ✅ **landed 2026-08-19** — writeonce-framework (HTTP/1.1 + router + Handler/Middleware) consumed by web-app through [deps]; h2c parked (§C) behind 8/9f/11. **v1 polish landed 2026-08-20** (branch framework-v1): get/post/put/delete_ helpers, 405+Allow, HEAD, Logging middleware, set_header; `just web-app` 16/0; fixed the interp-borrowed-field emitter crash en route |
| 17 | [library projects + `internal/`](stories/language-runtime-database/17-library-projects-internal.md) | ⬜ **forks settled 2026-08-20, awaiting spec/plan**: kind = "library" manifest key; Go internal/ rule, dep-boundary-only; lib+bin dual; VM/GC untouched by design (impact analysis in the iteration) | | 17 | [library projects + `internal/`](stories/language-runtime-database/17-library-projects-internal.md) | ⏸ **PARKED 2026-08-20** (developer directive; framework v1 first) — forks settled, spec + plan approved and ready on branch `library-internal`: kind = "library" key; Go internal/ rule, dep-boundary-only; lib+bin dual; VM/GC untouched by design |
--- ---
@ -176,10 +160,11 @@ that sequences its tasks. Read one, approve, then the next starts.
| Track | Item | Where | | Track | Item | Where |
| -------- | --------------------------------------------------------------------------- | ---------------------------------------------------------- | | -------- | --------------------------------------------------------------------------- | ---------------------------------------------------------- |
| Language | Iteration 17 — web framework as first-class library: spec/plan from the settled forks, then `kind = "library"` + `internal/` + framework reorg | [iteration 17](stories/language-runtime-database/17-library-projects-internal.md) | | Language | nothing active — the framework v1-polish slice landed 2026-08-20 (branch framework-v1, awaiting merge); next per the order: brainstorm 9c/9d's forks | [order](#implementation-order-re-sequenced-2026-08-20--framework-goal) |
Off-goal work is parked; the goal shifted 2026-08-20 from log-watcher (met) Off-goal work is parked; the goal (2026-08-20) is the web framework as a
to the web framework as a first-class library. polished micro-framework v1 — iteration 17 (library kind + `internal/`) is
parked with its spec + plan ready on branch `library-internal`.
### Landed 2026-08-14 — the compile-and-run milestone ### Landed 2026-08-14 — the compile-and-run milestone
@ -353,9 +338,10 @@ parked behind 8/9f/11; the post-12 parked list stays parked by the
2026-08-08 scope directive. (Iteration 7 dropped from this list — landed 2026-08-08 scope directive. (Iteration 7 dropped from this list — landed
2026-08-15.) 2026-08-15.)
1. **17** — THE goal slice: `kind = "library"` + `internal/` + framework 1. ~~**17**~~ — **PARKED 2026-08-20** (developer directive: framework v1
reorg; forks settled, compiler-driver-only, no runtime deps. Merge the first); spec + plan approved, ready on branch `library-internal` for
`web-framework` branch first. whenever it unparks. The framework v1-polish slice took its place and
landed the same day (branch framework-v1, `just web-app` 16/0).
2. **9c then 9d** — finish the half-done branches (ipc-attach: manifest + 2. **9c then 9d** — finish the half-done branches (ipc-attach: manifest +
binding; keypair-auth: manifest) before they rot; 9d folds into 9c's binding; keypair-auth: manifest) before they rot; 9d folds into 9c's
plan; both must precede 10. plan; both must precede 10.

View file

@ -108,10 +108,10 @@ fn main(args: multi Text) -> Int {
let app = App { middleware: [], routes: [] }; let app = App { middleware: [], routes: [] };
app.use_mw(Mw { m: Auth { token: token } }); app.use_mw(Mw { m: Auth { token: token } });
app.add(Route { method: "GET", pattern: "/products", h: ListProducts { pad: 0 } }); app.get("/products", ListProducts { pad: 0 });
app.add(Route { method: "GET", pattern: "/products/:name", h: ShowProduct { pad: 0 } }); app.get("/products/:name", ShowProduct { pad: 0 });
app.add(Route { method: "POST", pattern: "/products", h: CreateProduct { pad: 0 } }); app.post("/products", CreateProduct { pad: 0 });
app.add(Route { method: "POST", pattern: "/orders", h: CreateOrder { pad: 0 } }); app.post("/orders", CreateOrder { pad: 0 });
app.add(Route { method: "DELETE", pattern: "/products/:name", h: DeleteProduct { pad: 0 } }); app.delete_("/products/:name", DeleteProduct { pad: 0 });
return app.serve("127.0.0.1", port); return app.serve("127.0.0.1", port);
} }

View file

@ -20,7 +20,16 @@ writeonce-framework = { git = "https://github.com/shoneyj/writeonce-framework",
other client, so closing is the correct shape until shards/fibers (8/11). other client, so closing is the correct shape until shards/fibers (8/11).
A proxy in front simply reconnects. A proxy in front simply reconnects.
- **Router** (`router/`): method + path table with `:param` captures into - **Router** (`router/`): method + path table with `:param` captures into
`req.params`; first match wins; no match is the framework's 404. `req.params`; first match wins; a known path with the wrong method is
**405 with the `Allow` header** (registration order); no matching path is
the framework's 404. **HEAD is served free**: routed as GET, body
suppressed, `Content-Length` still names the body a GET would carry.
- **Registration helpers**: `app.get/post/put/delete_(pattern, handler)`
push the route for you (`delete_` because `delete` is the query
keyword); `app.add(Route { ... })` stays for anything else. A
request-line `Logging` middleware ships in `router/`, and
`set_header(resp, name, value)` is the escape hatch for headers the
builders don't set.
- **Handlers without closures**: the language has no function values by - **Handlers without closures**: the language has no function values by
doctrine, so a route handler is a class satisfying the `Handler` interface doctrine, so a route handler is a class satisfying the `Handler` interface
(`fn handle(req: Req) -> Resp`), dispatched structurally — a (`fn handle(req: Req) -> Resp`), dispatched structurally — a

View file

@ -25,22 +25,44 @@ pub class App {
push(self.routes, r); push(self.routes, r);
} }
-- Registration helpers — the ctor-literal-into-take shape, per method.
fn get(pattern: Text, take h: Handler) {
push(self.routes, Route { method: "GET", pattern: pattern, h: h });
}
fn post(pattern: Text, take h: Handler) {
push(self.routes, Route { method: "POST", pattern: pattern, h: h });
}
fn put(pattern: Text, take h: Handler) {
push(self.routes, Route { method: "PUT", pattern: pattern, h: h });
}
fn delete_(pattern: Text, take h: Handler) {
push(self.routes, Route { method: "DELETE", pattern: pattern, h: h });
}
fn dispatch(mut req: Req) -> Resp { fn dispatch(mut req: Req) -> Resp {
for mw in self.middleware { for mw in self.middleware {
let short = mw.m.before(req); let short = mw.m.before(req);
if short != nil { return short; } if short != nil { return short; }
} }
-- Path-first matching so a wrong-method hit on a known path answers
-- 405 with the Allow header (registration order) instead of a 404.
let allow = "";
for r in self.routes { for r in self.routes {
if r.method == req.method { let params: map<Text, Text> = {};
let params: map<Text, Text> = {}; if route_match(r.pattern, req.path, params) {
if route_match(r.pattern, req.path, params) { if r.method == req.method {
-- captures land on the borrowed request itself (mut) — a failed -- captures land on the borrowed request itself (mut) — a failed
-- match above never touched it, since captures collect locally -- match above never touched it, since captures collect locally
for k, v in params { req.params[k] = v; } for k, v in params { req.params[k] = v; }
return r.h.handle(req); return r.h.handle(req);
} }
if allow == "" { allow = "${r.method}"; } else { allow = "${allow}, ${r.method}"; }
} }
} }
if allow != "" { return method_not_allowed(allow); }
return not_found(); return not_found();
} }

View file

@ -20,13 +20,16 @@ fn status_text(code: Int) -> Text {
case 400: return "Bad Request"; case 400: return "Bad Request";
case 401: return "Unauthorized"; case 401: return "Unauthorized";
case 404: return "Not Found"; case 404: return "Not Found";
case 405: return "Method Not Allowed";
case 409: return "Conflict"; case 409: return "Conflict";
case 500: return "Internal Server Error"; case 500: return "Internal Server Error";
default: return "Status"; default: return "Status";
} }
} }
pub fn serialize(resp: Resp, keep: Bool) -> Text { -- head_only: HEAD answers — full status line + headers (Content-Length of
-- the body a GET would have sent) with the body itself suppressed.
pub fn serialize(resp: Resp, keep: Bool, head_only: Bool) -> Text {
let head = "HTTP/1.1 ${resp.status} ${status_text(resp.status)}\r\n"; let head = "HTTP/1.1 ${resp.status} ${status_text(resp.status)}\r\n";
for k, v in resp.headers { for k, v in resp.headers {
head = head .. "${k}: ${v}\r\n"; head = head .. "${k}: ${v}\r\n";
@ -37,6 +40,7 @@ pub fn serialize(resp: Resp, keep: Bool) -> Text {
head = head .. "Connection: close\r\n"; head = head .. "Connection: close\r\n";
} }
head = head .. "Content-Length: ${len(resp.body)}\r\n\r\n"; head = head .. "Content-Length: ${len(resp.body)}\r\n\r\n";
if head_only { return head; }
return head .. resp.body; return head .. resp.body;
} }
@ -54,13 +58,18 @@ pub fn serve(host: Text, port: Int, d: Dispatcher) -> Int {
if p == nil { alive = false; continue; } if p == nil { alive = false; continue; }
if p.closed { alive = false; continue; } if p.closed { alive = false; continue; }
if p.ok == false { if p.ok == false {
try net.write(c, serialize(bad_request("malformed request"), false)) catch (e) {} try net.write(c, serialize(bad_request("malformed request"), false, false)) catch (e) {}
alive = false; alive = false;
continue; continue;
} }
let r = p.req; let r = p.req;
if r == nil { alive = false; continue; } if r == nil { alive = false; continue; }
carry = p.rest; carry = p.rest;
-- HEAD is GET with the body suppressed: route and dispatch as GET,
-- serialize with head_only so Content-Length still names the body a
-- GET would have carried (RFC 9110 §9.3.2).
let is_head = r.method == "HEAD";
if is_head { r.method = "GET"; }
-- Connection policy for a single-threaded server: serve PIPELINED -- Connection policy for a single-threaded server: serve PIPELINED
-- requests on one connection (bytes already buffered), but close when -- requests on one connection (bytes already buffered), but close when
-- the client would idle — a parked keep-alive connection would block -- the client would idle — a parked keep-alive connection would block
@ -72,7 +81,7 @@ pub fn serve(host: Text, port: Int, d: Dispatcher) -> Int {
if to_lower(conn) == "close" { keep = false; } if to_lower(conn) == "close" { keep = false; }
} }
let resp = try d.dispatch(r) catch (e) server_error(); let resp = try d.dispatch(r) catch (e) server_error();
try net.write(c, serialize(resp, keep)) catch (e) { alive = false; } try net.write(c, serialize(resp, keep, is_head)) catch (e) { alive = false; }
if keep == false { alive = false; } if keep == false { alive = false; }
} }
net.close(c); net.close(c);

View file

@ -61,6 +61,13 @@ pub fn conflict(msg: Text) -> Resp {
return Resp { status: 409, headers: h, body: "{\"error\":\"${msg}\"}" }; return Resp { status: 409, headers: h, body: "{\"error\":\"${msg}\"}" };
} }
pub fn method_not_allowed(allow: Text) -> Resp {
let h: map<Text, Text> = {};
h["content-type"] = "application/json";
h["allow"] = allow;
return Resp { status: 405, headers: h, body: "{\"error\":\"method not allowed\"}" };
}
pub fn server_error() -> Resp { pub fn server_error() -> Resp {
let h: map<Text, Text> = {}; let h: map<Text, Text> = {};
h["content-type"] = "application/json"; h["content-type"] = "application/json";
@ -72,3 +79,9 @@ pub fn redirect(location: Text) -> Resp {
h["location"] = location; h["location"] = location;
return Resp { status: 302, headers: h, body: "" }; return Resp { status: 302, headers: h, body: "" };
} }
-- Set (or overwrite) one header on a built response — the escape hatch for
-- anything the builders don't cover: cache-control, etag, custom headers.
pub fn set_header(mut r: Resp, name: Text, value: Text) {
r.headers[name] = value;
}

View file

@ -30,6 +30,17 @@ pub class Mw {
m: Middleware m: Middleware
} }
-- Request-line logging, the one middleware every framework ships: method +
-- path to stderr, never short-circuits. `pad` is the record-class ctor
-- convention (every stateless handler carries one Int field).
pub class Logging {
pad: Int
fn before(req: Req) -> ?Resp {
print_err("${req.method} ${req.path}");
return nil;
}
}
-- Does `pattern` match `path`? Fills `params` with :name captures. -- Does `pattern` match `path`? Fills `params` with :name captures.
-- Segments split on '/', empties dropped (so "/a//b" == "/a/b" and the -- Segments split on '/', empties dropped (so "/a//b" == "/a/b" and the
-- root "/" is the empty segment list). First mismatch wins; a :segment -- root "/" is the empty segment list). First mismatch wins; a :segment

View file

@ -90,11 +90,15 @@ list, and a pointer to the plan document that already sequences its tasks.
Iterations 3 → 4 → 5 → 6 → 7 are the committed line; nothing off that Iterations 3 → 4 → 5 → 6 → 7 are the committed line; nothing off that
line lands before iteration 7's acceptance. Then 7b, then 8–12 (with 9b after the database engine). line lands before iteration 7's acceptance. Then 7b, then 8–12 (with 9b after the database engine).
- **Goal shifted 2026-08-20: log-watcher (iteration 7, landed 2026-08-15) - **Goal shifted 2026-08-20: log-watcher (iteration 7, landed 2026-08-15)
to the web framework as a first-class library.** Implementation order for to the web framework.** Same day, refined by directive: the framework
everything still pending: 17 → 9c/9d → 9e → 8 → 9f → 11 (+ h2c unparks) → stays a polished MICRO-framework (routing, middleware, `Req`/`Resp`) —
10 → 12 → 14/9g → 13 + parked drain. Rationale and forcing dependencies nothing MVC-scale — and iteration 17 (library kind + `internal/`) is
live in [`docs/00-status.md`](../../00-status.md) under "Implementation **parked** with spec + plan ready on branch `library-internal`. The
order". v1-polish slice landed 2026-08-20 (`just web-app` 16/0). Implementation
order for everything still pending: 9c/9d → 9e → 8 → 9f → 11 (+ h2c
unparks) → 10 → 12 → 14/9g → 13 + parked drain. Rationale and forcing
dependencies live in [`docs/00-status.md`](../../00-status.md) under
"Implementation order".
- **Iteration 7b (inserted 2026-08-11)** sits after the critical path - **Iteration 7b (inserted 2026-08-11)** sits after the critical path
deliberately: it delays nothing on the log-watcher line, and it must precede deliberately: it delays nothing on the log-watcher line, and it must precede
iteration 8 because the collector should be settled before shards multiply. iteration 8 because the collector should be settled before shards multiply.

View file

@ -13,6 +13,17 @@
> stored in containers now MOVE (`run/container-owned-move`), and a dep's > stored in containers now MOVE (`run/container-owned-move`), and a dep's
> internal `use` paths resolve dep-relatively. > internal `use` paths resolve dep-relatively.
> >
> **v1 polish LANDED 2026-08-20** (branch `framework-v1`, developer
> directive: ship routing/middleware/req-resp as a polished micro-framework,
> nothing MVC-scale): registration helpers `get/post/put/delete_` (the
> take-Handler shape, probe-proven — deviation 1 of the 16 plan retired),
> 405 + `Allow` on wrong-method path hits, HEAD served as GET with the body
> suppressed (RFC 9110 §9.3.2), a request-line `Logging` middleware,
> `set_header`; `just web-app` 16/0. It exposed and fixed a third compiler
> gap: a Text single-segment interpolation of a place crossed
> `let`/assignment boundaries uncopied — `copy_place_text` now sees through
> `Interp` (`run/interp-borrowed-field`).
>
> The framework is written IN writeonce and imported > The framework is written IN writeonce and imported
> like any dependency (iteration 15 is the prerequisite). TLS terminates at a > like any dependency (iteration 15 is the prerequisite). TLS terminates at a
> reverse proxy — browsers get TLS+ALPN+h2 from nginx/caddy while the > reverse proxy — browsers get TLS+ALPN+h2 from nginx/caddy while the

View file

@ -7,8 +7,13 @@
> as an iteration, do not implement yet). > as an iteration, do not implement yet).
> >
> **Refined 2026-08-20: all four forks SETTLED** (developer decisions, no code > **Refined 2026-08-20: all four forks SETTLED** (developer decisions, no code
> changed). See "Settled decisions" and "Impact analysis" below. Next step is > changed). See "Settled decisions" and "Impact analysis" below.
> the spec/plan; implementation stays parked until asked. >
> **⏸ PARKED 2026-08-20** (developer directive: framework v1 work first).
> The spec and plan were written and approved before parking; both sit ready
> on branch `library-internal`
> (`docs/superpowers/specs/2026-08-20-library-kind-internal-design.md`,
> `docs/superpowers/plans/2026-08-20-library-kind-internal.md`).
## Why this iteration exists ## Why this iteration exists

View file

@ -96,7 +96,57 @@ expect "unknown product is 404" "$(hit GET /products/none)" 404
expect "create order (FK)" "$(hit POST /orders '{"product":"mug","qty":2}')" 201 expect "create order (FK)" "$(hit POST /orders '{"product":"mug","qty":2}')" 201
expect "delete restricted by FK (409), server survives" "$(hit DELETE /products/mug)" 409 "reference" expect "delete restricted by FK (409), server survives" "$(hit DELETE /products/mug)" 409 "reference"
# ---- 11. pipelined keep-alive: two requests, one connection ---- # ---- 11. 405 on a known path with the wrong method (Allow header) ----
ma="$(timeout 5 python3 - "$PORT" <<'PYEOF'
import socket, sys
port = int(sys.argv[1])
s = socket.create_connection(("127.0.0.1", port), timeout=3)
s.sendall(b"PUT /products HTTP/1.1\r\nhost: a\r\nauthorization: Bearer s3cr3t\r\nconnection: close\r\ncontent-length: 0\r\n\r\n")
d = b""
while True:
got = s.recv(4000)
if not got: break
d += got
head = d.split(b"\r\n\r\n")[0].decode()
status = head.splitlines()[0].split(" ")[1]
allow = [l.split(":", 1)[1].strip() for l in head.splitlines() if l.lower().startswith("allow")]
print(status + "|" + (allow[0] if allow else ""))
PYEOF
)"
[[ "$ma" == "405|GET, POST" ]] && ok "405 + Allow on wrong method" \
|| bad "405" "got $ma (want 405|GET, POST)"
# ---- 12. HEAD: GET's headers, no body ----
hd="$(timeout 5 python3 - "$PORT" <<'PYEOF'
import socket, sys
port = int(sys.argv[1])
def raw(req):
s = socket.create_connection(("127.0.0.1", port), timeout=3)
s.sendall(req)
d = b""
while True:
got = s.recv(4000)
if not got: break
d += got
s.close()
return d
tail = b" /products HTTP/1.1\r\nhost: a\r\nauthorization: Bearer s3cr3t\r\nconnection: close\r\ncontent-length: 0\r\n\r\n"
g = raw(b"GET" + tail)
h = raw(b"HEAD" + tail)
ghead, _, gbody = g.partition(b"\r\n\r\n")
hhead, _, hbody = h.partition(b"\r\n\r\n")
def cl(head):
return [l.split(b":")[1].strip() for l in head.splitlines() if l.lower().startswith(b"content-length")][0]
status = hhead.decode().splitlines()[0].split(" ")[1]
print(f"{status}|{(cl(h[:len(hhead)]) == cl(g[:len(ghead)]))}|{len(hbody)}|{len(gbody)}")
PYEOF
)"
IFS='|' read -r hs same hb gb <<<"$hd"
[[ "$hs" == "200" && "$same" == "True" && "$hb" == "0" && "$gb" != "0" ]] \
&& ok "HEAD answers GET's Content-Length with no body" \
|| bad "HEAD" "status=$hs same-length=$same head-body=$hb get-body=$gb"
# ---- 13. pipelined keep-alive: two requests, one connection ----
n="$(timeout 5 python3 - "$PORT" <<'PYEOF' n="$(timeout 5 python3 - "$PORT" <<'PYEOF'
import socket, sys import socket, sys
port = int(sys.argv[1]) port = int(sys.argv[1])
@ -117,14 +167,14 @@ PYEOF
[[ "$n" == "2" ]] && ok "pipelined keep-alive (2 responses, 1 connection)" \ [[ "$n" == "2" ]] && ok "pipelined keep-alive (2 responses, 1 connection)" \
|| bad "pipelining" "expected 2 responses, got $n" || bad "pipelining" "expected 2 responses, got $n"
# ---- 12. SIGTERM stops it ---- # ---- 14. SIGTERM stops it ----
kill -TERM "$SRV" kill -TERM "$SRV"
stopped=1 stopped=1
for _ in $(seq 1 30); do kill -0 "$SRV" 2>/dev/null || { stopped=0; break; }; sleep 0.1; done for _ in $(seq 1 30); do kill -0 "$SRV" 2>/dev/null || { stopped=0; break; }; sleep 0.1; done
[[ $stopped -eq 0 ]] && ok "SIGTERM stops the server" || bad "stop" "still running" [[ $stopped -eq 0 ]] && ok "SIGTERM stops the server" || bad "stop" "still running"
SRV="" SRV=""
# ---- 13. restart persistence (WAL replay) ---- # ---- 15. restart persistence (WAL replay) ----
WA_TOKEN=s3cr3t WO_DATA="$DATA" "$W/app/target/web-app" "$PORT" >>"$W/srv.out" 2>&1 & WA_TOKEN=s3cr3t WO_DATA="$DATA" "$W/app/target/web-app" "$PORT" >>"$W/srv.out" 2>&1 &
SRV=$! SRV=$!
sleep 0.5 sleep 0.5