AtlatestRepositorysigil-http

sigil-http / tree / test / integrationrun-keepalive-range-tests.sh

1#!/usr/bin/env bash
2# Integration tests for T4 (keep-alive + chunked transfer-encoding) and
3# T2 (Range/206/416), driven against a server that links THIS repo's
4# working-tree sigil-http (via a from-path bundle, same technique as
5# run-streaming-tests.sh — a loose `sigil <file>` would resolve the RELEASED
6# package from the dep cache and silently test old code).
7#
8# Wire evidence is captured two ways:
9# - curl for connection reuse, headers, and Range status/sizes
10# - a raw Python socket for byte-exact chunked framing (0\r\n\r\n) and for
11# proving two serial requests are served on ONE connection.
13# Requires: curl, python3, a working `sigil` toolchain, network on first run.
14# test/integration/run-keepalive-range-tests.sh
16set -u
18SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
20SERVER_SRC="$SCRIPT_DIR/keepalive-range-server-main.sgl"
22APP_DIR="$(mktemp -d /tmp/t4t2-app.XXXXXX)"
23BIN="$APP_DIR/build/dev/bin/t4t2-server"
24ASSET="$(mktemp /tmp/t4t2-asset.XXXXXX.bin)"
25PORT=18251
26SRV_PID=""
27FAILED=0
29cleanup() {
30 [ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null
31 rm -rf "$APP_DIR" "$ASSET"
33trap cleanup EXIT
35pass() { echo "PASS: $1"; }
36fail() { echo "FAIL: $1"; FAILED=1; }
38scaffold_app() {
39 mkdir -p "$APP_DIR/src/t4t2-server"
40 cp "$SERVER_SRC" "$APP_DIR/src/t4t2-server/main.sgl"
41 cat > "$APP_DIR/package.sgl" <<EOF
42(package
43 name: "t4t2-server"
44 version: "0.1.0"
45 sigil: "^0.17"
46 description: "Ephemeral integration bundle for T4 keep-alive + T2 range"
47 entry: '(t4t2-server main)
48 bundle-name: "t4t2-server"
49 configs: (list
50 (config name: 'dev output-dir: "build/dev" static?: #f debug?: #t optimize: 0 bundle?: #t))
51 dependencies: (list
52 (from-git url: "codeberg:sigil/sigil" package: "sigil-run" version: "^0.17")
53 (from-git url: "codeberg:sigil/sigil" package: "sigil-stdlib" version: "^0.17")
54 (from-git url: "codeberg:sigil/sigil-json" version: "^0.16")
55 (from-path dir: "$REPO_ROOT" package: "sigil-http")))
56EOF
59start_server() {
60 "$BIN" "$PORT" "$ASSET" >"$APP_DIR/server.log" 2>&1 &
61 SRV_PID=$!
62 local i
63 for i in $(seq 1 60); do
64 if curl -s --max-time 2 "http://127.0.0.1:$PORT/hello" >/dev/null 2>&1; then
65 return 0
66 fi
67 if ! kill -0 "$SRV_PID" 2>/dev/null; then
68 echo " server died on startup; log:"; cat "$APP_DIR/server.log"
69 return 1
70 fi
71 sleep 0.5
72 done
73 echo " server never became ready"; return 1
76echo "Scaffolding + building integration bundle (from-path sigil-http)..."
77scaffold_app
78( cd "$APP_DIR" && sigil deps install >/dev/null 2>&1 && sigil build >"$APP_DIR/build.log" 2>&1 )
79if [ ! -x "$BIN" ]; then
80 echo "FAIL: bundle build did not produce $BIN"
81 tail -20 "$APP_DIR/build.log" 2>/dev/null
82 exit 1
83fi
85# 4 KB asset for range tests.
86head -c 4096 /dev/urandom > "$ASSET"
87ASSET_SIZE=$(stat -c%s "$ASSET")
89if ! start_server; then
90 echo "Some integration tests FAILED (startup)."; exit 1
91fi
93# ---------------------------------------------------------------------------
94# T4.1 — keep-alive: two requests reuse ONE TCP connection (curl).
95# ---------------------------------------------------------------------------
96REUSE=$(curl -sv --http1.1 "http://127.0.0.1:$PORT/hello" "http://127.0.0.1:$PORT/hello" 2>&1 \
97 | grep -c 'Re-using existing connection')
98if [ "$REUSE" -ge 1 ]; then
99 pass "T4 keep-alive: curl re-used the connection for a 2nd request"
100else
101 fail "T4 keep-alive: curl did NOT re-use the connection (count=$REUSE)"
102fi
104# ---------------------------------------------------------------------------
105# T4.2 — response advertises Connection: keep-alive + Content-Length (HTTP/1.1).
106# ---------------------------------------------------------------------------
107H=$(curl -s --http1.1 -D - -o /dev/null "http://127.0.0.1:$PORT/hello")
108if echo "$H" | grep -qi '^connection: *keep-alive' && echo "$H" | grep -qi '^content-length:'; then
109 pass "T4 keep-alive: Connection: keep-alive + Content-Length present"
110else
111 fail "T4 keep-alive: missing keep-alive/Content-Length headers"; echo "$H" | sed 's/^/ /'
112fi
114# ---------------------------------------------------------------------------
115# T4.3 — explicit Connection: close is honored.
116# ---------------------------------------------------------------------------
117H=$(curl -s --http1.1 -H 'Connection: close' -D - -o /dev/null "http://127.0.0.1:$PORT/hello")
118if echo "$H" | grep -qi '^connection: *close'; then
119 pass "T4 keep-alive: Connection: close honored"
120else
121 fail "T4 keep-alive: Connection: close NOT honored"; echo "$H" | sed 's/^/ /'
122fi
124# ---------------------------------------------------------------------------
125# T4.4 — chunked framing over the wire (raw socket: exact bytes + terminator).
126# ---------------------------------------------------------------------------
127python3 - "$PORT" <<'PY'
128import socket, sys
129port = int(sys.argv[1])
130s = socket.create_connection(("127.0.0.1", port), timeout=5)
131s.sendall(b"GET /chunked HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
132data = b""
133s.settimeout(5)
134try:
135 while True:
136 b = s.recv(4096)
137 if not b: break
138 data += b
139except socket.timeout:
140 pass
141s.close()
142head, _, body = data.partition(b"\r\n\r\n")
143ok = True
144if b"transfer-encoding: chunked" not in head.lower():
145 print(" raw: missing Transfer-Encoding: chunked header"); ok = False
146if b"content-length" in head.lower():
147 print(" raw: unexpected Content-Length on chunked response"); ok = False
148# Expect chunk sizes 7 (=0x7) for each 'chunk-X' and a terminator.
149if b"7\r\nchunk-A\r\n" not in body:
150 print(" raw: chunk-A not framed as '7\\r\\nchunk-A\\r\\n'"); ok = False
151if not body.rstrip(b"\r\n").endswith(b"0") and not body.endswith(b"0\r\n\r\n"):
152 print(" raw: missing terminating 0-chunk"); ok = False
153if b"0\r\n\r\n" not in body:
154 print(" raw: no 0\\r\\n\\r\\n terminator in body"); ok = False
155sys.exit(0 if ok else 1)
156PY
157if [ $? -eq 0 ]; then
158 pass "T4 chunked: valid chunk framing + terminating 0\\r\\n\\r\\n (raw socket)"
159else
160 fail "T4 chunked: framing/terminator check failed"
161fi
163# ---------------------------------------------------------------------------
164# T4.5 — TWO serial requests served on ONE connection (raw socket, definitive).
165# ---------------------------------------------------------------------------
166python3 - "$PORT" <<'PY'
167import socket, sys
168port = int(sys.argv[1])
170def read_by_content_length(s):
171 buf = b""
172 while b"\r\n\r\n" not in buf:
173 b = s.recv(1)
174 if not b: return None, b""
175 buf += b
176 head, _, rest = buf.partition(b"\r\n\r\n")
177 cl = 0
178 for line in head.split(b"\r\n"):
179 if line.lower().startswith(b"content-length:"):
180 cl = int(line.split(b":")[1].strip())
181 while len(rest) < cl:
182 chunk = s.recv(cl - len(rest))
183 if not chunk: break
184 rest += chunk
185 return head, rest
187s = socket.create_connection(("127.0.0.1", port), timeout=5)
188s.settimeout(5)
189req = b"GET /hello HTTP/1.1\r\nHost: x\r\n\r\n"
190s.sendall(req)
191h1, b1 = read_by_content_length(s)
192s.sendall(req) # SAME socket, second request
193h2, b2 = read_by_content_length(s)
194s.close()
195ok = (h1 and h2 and b1 == b"Hello, World!" and b2 == b"Hello, World!"
196 and b" 200 " in h1.split(b"\r\n")[0] and b" 200 " in h2.split(b"\r\n")[0])
197if not ok:
198 print(" raw serial: b1=%r b2=%r" % (b1, b2))
199sys.exit(0 if ok else 1)
200PY
201if [ $? -eq 0 ]; then
202 pass "T4 keep-alive: two serial requests served on one socket (raw, no reconnect)"
203else
204 fail "T4 keep-alive: serial-reuse on one socket failed"
205fi
207# ---------------------------------------------------------------------------
208# T2.1 — Range bytes=0-99 -> 206 + Content-Range + 100 bytes.
209# ---------------------------------------------------------------------------
210DL="$(mktemp)"
211H=$(curl -s -r 0-99 -D - -o "$DL" "http://127.0.0.1:$PORT/file")
212CODE=$(echo "$H" | head -1 | grep -oE '[0-9]{3}')
213SZ=$(stat -c%s "$DL")
214if [ "$CODE" = "206" ] && echo "$H" | grep -qi "^content-range: *bytes 0-99/$ASSET_SIZE" && [ "$SZ" = "100" ]; then
215 pass "T2 range: bytes=0-99 -> 206, Content-Range bytes 0-99/$ASSET_SIZE, 100 bytes"
216else
217 fail "T2 range: bytes=0-99 (code=$CODE size=$SZ)"; echo "$H" | sed 's/^/ /'
218fi
220# T2.2 — suffix bytes=-500 -> 206 + last 500 bytes.
221H=$(curl -s -r -500 -D - -o "$DL" "http://127.0.0.1:$PORT/file")
222CODE=$(echo "$H" | head -1 | grep -oE '[0-9]{3}')
223SZ=$(stat -c%s "$DL")
224EXP_START=$((ASSET_SIZE - 500))
225if [ "$CODE" = "206" ] && echo "$H" | grep -qi "^content-range: *bytes $EXP_START-$((ASSET_SIZE-1))/$ASSET_SIZE" && [ "$SZ" = "500" ]; then
226 pass "T2 range: suffix bytes=-500 -> 206, last 500 bytes"
227else
228 fail "T2 range: suffix bytes=-500 (code=$CODE size=$SZ)"; echo "$H" | sed 's/^/ /'
229fi
231# T2.3 — open-ended bytes=500- -> 206 remainder.
232H=$(curl -s -r 500- -D - -o "$DL" "http://127.0.0.1:$PORT/file")
233CODE=$(echo "$H" | head -1 | grep -oE '[0-9]{3}')
234SZ=$(stat -c%s "$DL")
235if [ "$CODE" = "206" ] && [ "$SZ" = "$((ASSET_SIZE-500))" ]; then
236 pass "T2 range: open-ended bytes=500- -> 206, $((ASSET_SIZE-500)) bytes"
237else
238 fail "T2 range: open-ended bytes=500- (code=$CODE size=$SZ)"
239fi
241# T2.4 — unsatisfiable -> 416 + Content-Range: bytes */total.
242H=$(curl -s -H "Range: bytes=$((ASSET_SIZE+1000))-" -D - -o /dev/null "http://127.0.0.1:$PORT/file")
243CODE=$(echo "$H" | head -1 | grep -oE '[0-9]{3}')
244if [ "$CODE" = "416" ] && echo "$H" | grep -qi "^content-range: *bytes \*/$ASSET_SIZE"; then
245 pass "T2 range: unsatisfiable -> 416, Content-Range bytes */$ASSET_SIZE"
246else
247 fail "T2 range: unsatisfiable (code=$CODE)"; echo "$H" | sed 's/^/ /'
248fi
250# T2.5 — no Range -> 200, full body byte-exact.
251curl -s -D - -o "$DL" "http://127.0.0.1:$PORT/file" >"$APP_DIR/full-head.txt"
252if head -1 "$APP_DIR/full-head.txt" | grep -q ' 200 ' && cmp -s "$ASSET" "$DL"; then
253 pass "T2 range: no Range -> 200, byte-exact full file ($ASSET_SIZE bytes)"
254else
255 fail "T2 range: full-file 200 mismatch"
256fi
257rm -f "$DL"
259echo ""
260if [ "$FAILED" -eq 0 ]; then
261 echo "All keep-alive + range integration tests passed."
262 exit 0
263else
264 echo "Some keep-alive + range integration tests FAILED."
265 echo "--- server log ---"; cat "$APP_DIR/server.log"
266 exit 1
267fi