Add keep-alive + chunked responses (T4) and Range/206 (T2)
T4 - persistent connections + chunked transfer-encoding: - Non-streaming HTTP/1.1 responses keep the socket open and serve the next request on it; honor Connection: close and default HTTP/1.0 to close. Refresh the age-based timeout per request so it acts as an idle timeout for kept-alive connections. - Frame unknown-length streaming bodies (SSE, procedure bodies) with Transfer-Encoding: chunked and a terminating 0rnrn. http-response/file keeps Content-Length framing. - Only keep-alive when the response has a determinable length; a HEAD to a chunked-style route would otherwise leave the connection unframed.
T2 - Range requests / 206 / 416: - http-response/file gains a range: keyword (raw Range header). Supports bytes=A-B, bytes=A-, and suffix bytes=-N; emits 206 + Content-Range, 416 + Content-Range: bytes */total, or a full 200. Adds resolve-range and HTTP-RANGE-NOT-SATISFIABLE.
Tests: 23 response-level unit tests plus a curl + raw-socket integration harness proving connection reuse, chunked framing, and 206/416/full-file. Existing streaming/SSE/HEAD/gzip integration suite unchanged.
CHANGELOG.md | 31 +++++++++++++++++
docs/http.md | 30 ++++++++++++++++
src/sigil/http/response.sgl | 237 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
src/sigil/http/server.sgl | 78 ++++++++++++++++++++++++++++++++++++------
test/integration/keepalive-range-server-main.sgl | 56 ++++++++++++++++++++++++++++++
test/integration/run-keepalive-range-tests.sh | 267 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/test-response.sgl | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 834 insertions(+), 50 deletions(-)CHANGELOG.mdmodified
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).## [Unreleased]### Added- **Persistent connections (keep-alive).** Non-streaming HTTP/1.1 responses now keep the TCP connection open and serve subsequent requests on the same socket instead of forcing `Connection: close` after every response. The server honors an explicit `Connection: close` and defaults HTTP/1.0 to close; the age-based timeout is refreshed per request so it acts as an idle timeout for kept-alive connections. This removes the connection-churn that amplified image-heavy page loads.- **Chunked response transfer-encoding.** Streaming responses whose length is unknown (SSE and bare procedure bodies) are now framed with `Transfer-Encoding: chunked` and a terminating `0\r\n\r\n`, instead of relying on connection close for framing. `http-response/file` keeps its `Content-Length` framing (byte-exact).- **`Range:` requests and `206 Partial Content`.** `http-response/file` accepts a `range:` keyword (the raw request `Range` header) and honors byte ranges: `bytes=A-B`, open-ended `bytes=A-`, and suffix `bytes=-N`. Satisfiable ranges yield `206` with `Content-Range`; unsatisfiable ranges yield `416 Range Not Satisfiable` with `Content-Range: bytes */<total>`; an absent or malformed header falls back to a full `200`. Adds the `HTTP-RANGE-NOT-SATISFIABLE` constant and exports `resolve-range`.### Notes- Reusing a **streamed** connection for keep-alive is not yet supported — a streaming response still closes when the stream ends (the select loop would need to re-adopt the goroutine-owned socket). HTTP request pipelining is also unsupported. Both are tracked follow-ups.## [0.17.0] - 2026-07-10### Changeddocs/http.mdmodified
body: (json-encode #{ id: 123 }))```### File Serving and Range Requests`http-response/file` streams a file from disk with an auto-detected MIME type.Pass `range:` the request's raw `Range` header to honor byte ranges — mediaseeking and resumable downloads:```scheme;; Whole file (200 OK), advertises Accept-Ranges: bytes(http-response/file "/srv/video.mp4");; Honor the request Range header: 206 Partial Content for a satisfiable;; range, 416 Range Not Satisfiable otherwise, 200 when there is no Range.(http-response/file "/srv/video.mp4" range: (http-request-header req "Range"))```Supported range forms: `bytes=A-B` (first–last), `bytes=A-` (open-ended), and`bytes=-N` (the last N bytes). A `206` response carries `Content-Range` and theexact `Content-Length`; a `416` carries `Content-Range: bytes */<total>`.### Persistent Connections (keep-alive)Non-streaming HTTP/1.1 responses keep the connection open and serve subsequentrequests on the same socket. The server honors an explicit `Connection: close`and closes HTTP/1.0 connections by default — no configuration required.Streaming responses of unknown length (SSE, procedure bodies) are framed with`Transfer-Encoding: chunked`.### Form Parsing```schemeHTTP-OK ; 200HTTP-CREATED ; 201HTTP-NO-CONTENT ; 204HTTP-PARTIAL-CONTENT ; 206HTTP-MOVED-PERMANENTLY ; 301HTTP-FOUND ; 302HTTP-NOT-MODIFIED ; 304HTTP-BAD-REQUEST ; 400HTTP-RANGE-NOT-SATISFIABLE ; 416HTTP-UNAUTHORIZED ; 401HTTP-FORBIDDEN ; 403HTTP-NOT-FOUND ; 404src/sigil/http/response.sglmodified
HTTP-PAYLOAD-TOO-LARGE HTTP-URI-TOO-LONG HTTP-UNSUPPORTED-MEDIA-TYPE HTTP-RANGE-NOT-SATISFIABLE HTTP-INTERNAL-SERVER-ERROR HTTP-NOT-IMPLEMENTED HTTP-BAD-GATEWAY ;; File serving http-response/file parse-range-header resolve-range ;; Compression response-compressible? (define HTTP-PAYLOAD-TOO-LARGE 413) (define HTTP-URI-TOO-LONG 414) (define HTTP-UNSUPPORTED-MEDIA-TYPE 415) (define HTTP-RANGE-NOT-SATISFIABLE 416) (define HTTP-INTERNAL-SERVER-ERROR 500) (define HTTP-NOT-IMPLEMENTED 501) (define HTTP-BAD-GATEWAY 502) ((= code 413) "Payload Too Large") ((= code 414) "URI Too Long") ((= code 415) "Unsupported Media Type") ((= code 416) "Range Not Satisfiable") ((= code 500) "Internal Server Error") ((= code 501) "Not Implemented") ((= code 502) "Bad Gateway") (cdr entry) "\r\n")))))) ;;; Ensure required headers are present (define (ensure-headers headers body) ;;; Ensure required framing headers are present. ;;; ;;; `keep-alive` selects the default `Connection` value (only applied when ;;; the response didn't already set one — e.g. SSE sets `keep-alive`). ;;; `chunked` requests `Transfer-Encoding: chunked` framing for a ;;; streaming body of unknown length: it adds that header and suppresses ;;; the `Content-Length` (the two are mutually exclusive per RFC 7230). ;;; ;;; Defaults (`keep-alive` #f, `chunked` #f) reproduce the historical ;;; behavior: `Connection: close` and a `Content-Length` when the length ;;; is known — so non-server callers are unaffected. (define (ensure-headers headers body (keys: (keep-alive #f) (chunked #f))) (let* ((has-content-type (dict-ref headers content-type: #f)) (has-content-length (dict-ref headers content-length: #f)) (has-connection (dict-ref headers connection: #f)) (has-transfer-encoding (dict-ref headers transfer-encoding: #f)) (len (body-length body)) (result headers)) ;; Add Content-Length if body has known length (when (and len (not has-content-length) (not (streaming-body? body))) (set! result (dict-set result content-length: (number->string len)))) (if (and chunked (not has-content-length)) ;; Chunked framing: advertise it and do NOT emit Content-Length. (when (not has-transfer-encoding) (set! result (dict-set result transfer-encoding: "chunked"))) ;; Otherwise add Content-Length when the length is known. (when (and len (not has-content-length) (not (streaming-body? body))) (set! result (dict-set result content-length: (number->string len))))) ;; Add default Content-Type if body present but no type (when (and body (not has-content-type)) (set! result (dict-set result content-type: "application/octet-stream"))) ;; Add Connection: close if not already set (SSE sets keep-alive) ;; Default Connection unless the response set one explicitly. (when (not has-connection) (set! result (dict-set result connection: "close"))) (set! result (dict-set result connection: (if keep-alive "keep-alive" "close")))) result)) ;; Frame one already-materialized chunk (a bytevector) for ;; Transfer-Encoding: chunked: <hex-length>\r\n<bytes>\r\n ;; The length is the byte count, so it is correct for multi-byte text. ;; Callers must not frame an empty payload (a zero-length chunk is the ;; terminator). (define (frame-chunk bv) (let ((header (string->utf8 (string-append (number->string (bytevector-length bv) 16) "\r\n")))) (bytevector-append header bv (string->utf8 "\r\n")))) ;; The terminating zero-length chunk that ends a chunked body. (define chunked-terminator "0\r\n\r\n") ;;; Write an HTTP response to a socket. ;;; ;;; The write function should accept `(socket data)` and return bytes ;;; invoking a streaming producer. `ensure-headers` still computes the ;;; Content-Length from the body, so a HEAD response advertises the same ;;; headers a GET would while sending zero body bytes. (define (write-http-response res sock write-fn (keys: (head? #f))) (: http-response? any? procedure? (head?: boolean?) -> boolean?) ;;; ;;; `keep-alive:` sets the default `Connection` header (see `ensure-headers`). ;;; `chunked:` frames a streaming (procedure) body with ;;; `Transfer-Encoding: chunked` — each producer write becomes a chunk and ;;; a terminating `0\r\n\r\n` is emitted when the producer returns. It only ;;; affects procedure bodies; string/bytevector bodies always use ;;; Content-Length framing. (define (write-http-response res sock write-fn (keys: (head? #f) (keep-alive #f) (chunked #f))) (: http-response? any? procedure? (head?: boolean?) (keep-alive: boolean?) (chunked: boolean?) -> boolean?) (let* ((status (http-response-status res)) (body (http-response-body res)) (headers (ensure-headers (http-response-headers res) body)) (headers (ensure-headers (http-response-headers res) body keep-alive: keep-alive chunked: chunked)) (status-line (build-status-line status)) (headers-str (build-headers-string headers))) ;; Write status line, headers, blank line, then body ((bytevector? body) (if (write-fn sock body) #t #f)) ((procedure? body) ;; Streaming body - call producer with write/close callbacks ;; Streaming body - call producer with write/close callbacks. ;; When chunked, each non-empty write is chunk-framed and a ;; terminating zero chunk is sent after the producer returns. (let ((closed #f)) (body ;; write callback (lambda (data) (if closed #f (if (write-fn sock data) #t #f))) (if chunked ;; Materialize once, then skip empty payloads — a ;; 0-length chunk would be read as the terminator ;; mid-stream. (let ((bv (if (string? data) (string->utf8 data) data))) (if (= (bytevector-length bv) 0) #t (if (write-fn sock (frame-chunk bv)) #t #f))) (if (write-fn sock data) #t #f)))) ;; close callback (lambda () (set! closed #t))) ;; Terminate the chunked stream. (when chunked (write-fn sock chunked-terminator)) #t)) (else #f))))) ;;; Parse an HTTP Range header value into (start . end). ;;; Returns #f if the header is absent or malformed. ;;; ;;; Only single byte-ranges are supported. Three forms are recognized: ;;; - `bytes=A-B` first-last, inclusive => (A . B) ;;; - `bytes=A-` open-ended from A to EOF => (A . #f) ;;; - `bytes=-N` suffix: the last N bytes => (#f . N) ;;; ;;; The suffix form uses a `#f` start to distinguish "last N bytes" from ;;; an absolute range starting at N; `resolve-range` turns either into ;;; concrete indices against the file size. ;;; ;;; ```scheme ;;; (parse-range-header "bytes=0-499") ; => (0 . 499) ;;; (parse-range-header "bytes=500-") ; => (500 . #f) ;;; (parse-range-header "bytes=-500") ; => (#f . 500) ;;; (parse-range-header #f) ; => #f ;;; ``` (define (parse-range-header header) #f (let* ((start-str (substring range-str 0 dash-pos)) (end-str (substring range-str (+ dash-pos 1) (string-length range-str))) (start (string->number start-str)) (start (if (string-empty? start-str) #f (string->number start-str))) (end (if (string-empty? end-str) #f (string->number end-str)))) (if start (cons start end) #f)))))))) (cond ;; Absolute range: start present (end optional). (start (cons start end)) ;; Suffix range `bytes=-N`: start absent, end present. (end (cons #f end)) ;; `bytes=-` or otherwise empty — malformed. (else #f))))))))) ;;; Resolve a parsed Range (from `parse-range-header`) against a known ;;; total file size into concrete inclusive byte indices. ;;; ;;; Returns `(start . end)` (both inclusive, satisfiable), or the symbol ;;; `unsatisfiable` when the range cannot be served (caller should emit ;;; 416). `total` is the file's byte length. ;;; ;;; ```scheme ;;; (resolve-range (cons 0 99) 1000) ; => (0 . 99) ;;; (resolve-range (cons 500 #f) 1000) ; => (500 . 999) ;;; (resolve-range (cons #f 500) 1000) ; => (500 . 999) ; last 500 bytes ;;; (resolve-range (cons 2000 #f) 1000); => unsatisfiable ;;; ``` (define (resolve-range parsed total) (let ((start (car parsed)) (end (cdr parsed))) (cond ;; Suffix `bytes=-N`: the last N bytes. A zero-length suffix, or a ;; suffix of an empty file, is unsatisfiable. ((not start) (if (or (not end) (<= end 0) (= total 0)) 'unsatisfiable (let ((n (if (> end total) total end))) (cons (- total n) (- total 1))))) ;; A start at or past EOF cannot be satisfied. ((>= start total) 'unsatisfiable) ;; Absolute range; clamp the end to the last byte. (else (let ((real-end (if end (if (> end (- total 1)) (- total 1) end) (- total 1)))) (if (< real-end start) 'unsatisfiable (cons start real-end))))))) ;;; Serve a file with automatic MIME type and optional Range support. ;;; Uses streaming I/O to send files in chunks without loading (close-input-port port) (close))) (define (http-response/file path (keys: (range-start #f) (range-end #f))) ;; Build a 206 Partial Content response for inclusive byte indices ;; [start, end] of a file whose total size is `total`. (define (file-partial-response path total mime start end) (let* ((length (+ (- end start) 1)) (content-range (str "bytes " (number->string start) "-" (number->string end) "/" (number->string total)))) (http-response status: HTTP-PARTIAL-CONTENT headers: (dict content-type: mime content-length: (number->string length) content-range: content-range accept-ranges: "bytes") body: (lambda (write-chunk close) (stream-file-range write-chunk close path start length))))) ;; Build a full 200 OK streaming response for the whole file. (define (file-full-response path total mime) (http-response status: HTTP-OK headers: (dict content-type: mime content-length: (number->string total) accept-ranges: "bytes") body: (lambda (write-chunk close) (stream-file-range write-chunk close path 0 total)))) ;; Build a 416 Range Not Satisfiable response, advertising the total size. (define (file-unsatisfiable-response total) (http-response status: HTTP-RANGE-NOT-SATISFIABLE headers: (dict content-type: "text/plain; charset=utf-8" content-range: (str "bytes */" (number->string total)) accept-ranges: "bytes") body: "Requested Range Not Satisfiable")) ;;; Serve a file with automatic MIME type and optional Range support. ;;; ;;; With no range arguments the whole file is streamed as `200 OK`. ;;; ;;; Pass `range:` the raw request `Range` header value (e.g. ;;; `(http-request-header req "Range")`) to honor byte ranges: a ;;; satisfiable range yields `206 Partial Content` with `Content-Range`; ;;; an unsatisfiable one yields `416 Range Not Satisfiable`; an absent or ;;; malformed header falls back to the full `200` response. This is the ;;; recommended way to wire incoming requests. ;;; ;;; `range-start:`/`range-end:` remain for callers that have already ;;; resolved absolute indices (they bypass parsing and 416 handling). ;;; ;;; ```scheme ;;; (http-response/file "/path/to/video.mp4") ;;; (http-response/file "/path/to/video.mp4" range: (http-request-header req "Range")) ;;; (http-response/file "/path/to/video.mp4" range-start: 0 range-end: 499) ;;; ``` (define (http-response/file path (keys: (range-start #f) (range-end #f) (range #f))) (let* ((total (file-size path)) (mime (mime-type-for-file path))) (if range-start (let* ((end (or range-end (- total 1))) (length (+ (- end range-start) 1)) (content-range (str "bytes " (number->string range-start) "-" (number->string end) "/" (number->string total)))) (http-response status: HTTP-PARTIAL-CONTENT headers: (dict content-type: mime content-length: (number->string length) content-range: content-range accept-ranges: "bytes") body: (lambda (write-chunk close) (stream-file-range write-chunk close path range-start length)))) (http-response status: HTTP-OK headers: (dict content-type: mime content-length: (number->string total) accept-ranges: "bytes") body: (lambda (write-chunk close) (stream-file-range write-chunk close path 0 total)))))) (cond ;; Raw Range header wiring — parse + resolve, may yield 206 or 416. (range (let ((parsed (parse-range-header range))) (if (not parsed) ;; Absent/malformed Range → full 200 (per RFC 7233 §3.1). (file-full-response path total mime) (let ((resolved (resolve-range parsed total))) (if (eq? resolved 'unsatisfiable) (file-unsatisfiable-response total) (file-partial-response path total mime (car resolved) (cdr resolved))))))) ;; Pre-resolved absolute indices. (range-start (file-partial-response path total mime range-start (or range-end (- total 1)))) ;; No range at all → full file. (else (file-full-response path total mime))))) ;; ============================================================ ;; Server-Sent Events (SSE)src/sigil/http/server.sglmodified
(string-length line))))) (loop (cdr lines) headers)))))) ;; Should this request's connection stay open after the response? ;; HTTP/1.1 defaults to persistent; HTTP/1.0 defaults to close. An explicit ;; `Connection: close` always closes; `Connection: keep-alive` keeps an ;; HTTP/1.0 connection open. (define (request-wants-keep-alive? request) (let* ((version (http-request-version request)) (conn (http-request-header request "Connection")) (conn-lc (and conn (string-downcase conn)))) (cond ((and conn-lc (string-contains? conn-lc "close")) #f) ((and conn-lc (string-contains? conn-lc "keep-alive")) #t) ((and (string? version) (string=? version "HTTP/1.1")) #t) (else #f)))) ;; Does the response carry an explicit Content-Length (i.e. known framing, ;; so a streaming body can be delimited without chunked encoding)? (define (response-has-length? response) (and (dict-ref (http-response-headers response) content-length: #f) #t)) ;; Will the response we send on the normal (non-streaming) path carry a ;; determinable Content-Length? True for string/bytevector/#f bodies (whose ;; length ensure-headers computes) and for any response with an explicit ;; Content-Length header. A procedure body without an explicit length is ;; NOT determinable — this only occurs for a HEAD to a chunked-style route, ;; where keep-alive would leave the connection unframed, so we must close. (define (response-known-length? response) (or (response-has-length? response) (not (procedure? (http-response-body response))))) ;; Reset a client's per-request parse state so the same (kept-alive) socket ;; can serve the next request. `created-at` is refreshed so the age-based ;; timeout measures idle time since the last completed request rather than ;; since the connection was accepted. (define (reset-client-for-keep-alive client) (http-client client buffer: (make-bytevector 0) headers-complete: #f content-length: #f body-bytes-read: 0 request: #f response-started: #f created-at: (current-milliseconds))) ;;; Handle a complete request (define (handle-request server client request) (let ((handler (http-server-handler server)) (let ((body (http-response-body response)) (head? (eq? (http-request-method request) 'HEAD))) (if (and (procedure? body) (not head?)) ;; Streaming response - spawn goroutine and keep socket open (begin ;; Streaming response — a goroutine owns the socket. Frame with ;; chunked transfer-encoding when the length is unknown (SSE, ;; bare producers); http-response/file keeps its Content-Length ;; framing. The connection is closed when the stream ends — ;; reusing a streamed socket for keep-alive is a documented ;; follow-up (needs the select loop to re-adopt the socket). (let ((chunked? (not (response-has-length? response)))) (go (guard (exn (else (log-server-error "Streaming response goroutine crashed" exn))) (send-response sock response) (send-response sock response chunked: chunked?) (socket-close sock))) #f) ; Remove from normal client list (goroutine owns socket now) ;; Normal response (or HEAD) - send and close (begin (send-response sock response head?: head?) (socket-close sock) #f)))))) ; Remove client from list ;; Normal response (or HEAD). Keep the connection alive for ;; HTTP/1.1 (unless the client asked to close) ONLY when the ;; response has a determinable Content-Length — otherwise the ;; peer can't frame a persistent connection, so we must close. ;; Reset the client's parse state and keep it in the select ;; loop to read the next request on the same socket. (let ((keep? (and (request-wants-keep-alive? request) (http-server-running server) (response-known-length? response)))) (send-response sock response head?: head? keep-alive: keep?) (if keep? (reset-client-for-keep-alive client) (begin (socket-close sock) #f)))))))) ;;; Send a response to socket. ;;; ;;; so we loop until the full data is sent. Converts strings to ;;; bytevectors for correct byte-offset tracking (socket-write ;;; returns bytes written, not characters). (define (send-response sock response (keys: (head? #f))) (define (send-response sock response (keys: (head? #f) (keep-alive #f) (chunked #f))) (write-http-response response sock (lambda (s data) (let* ((bv (if (string? data) (string->utf8 data) data)) (await-writable s) (loop offset (+ attempts 1))) #f))))))) head?: head?)) head?: head? keep-alive: keep-alive chunked: chunked)) ;;; Send an error response (define (send-error-response sock status message)test/integration/keepalive-range-server-main.sgladded
;;; (t4t2-server main) - integration test server for T4 (keep-alive + chunked);;; and T2 (Range/206/416).;;;;;; Usage: t4t2-server <port> <asset-path>;;;;;; Routes:;;; /hello -> plain 200, known length (keep-alive eligible);;; /chunked -> streaming procedure body WITHOUT Content-Length;;; (server frames it as Transfer-Encoding: chunked);;; /file -> http-response/file wired to the request Range header;;; (200 full, 206 partial, or 416 unsatisfiable);;;;;; Started as a BARE http-serve (no with-async) so the T1 self-scheduler;;; keeps working alongside keep-alive.(define-library (t4t2-server main) (import (sigil core) (sigil io) (sigil process) (sigil http server) (sigil http request) (sigil http response)) (export main) (begin (define *asset-path* (make-parameter "/dev/null")) (define (handler request) (let ((path (http-request-path request))) (cond ((string=? path "/hello") (http-response/text HTTP-OK "Hello, World!")) ;; Streaming body, unknown length -> chunked transfer-encoding. ((string=? path "/chunked") (http-response status: HTTP-OK headers: #{ content-type: "text/plain" } body: (lambda (write-chunk close) (write-chunk "chunk-A") (write-chunk "chunk-B") (write-chunk "chunk-C") (close)))) ;; File with Range wiring (T2). ((string=? path "/file") (http-response/file (*asset-path*) range: (http-request-header request "Range"))) (else (http-response/not-found))))) (define (main) (let* ((args (cdr (command-line))) (port (string->number (list-ref args 0))) (asset (list-ref args 1))) (*asset-path* asset) (http-serve handler port: port host: "127.0.0.1")))))test/integration/run-keepalive-range-tests.shadded
#!/usr/bin/env bash# Integration tests for T4 (keep-alive + chunked transfer-encoding) and# T2 (Range/206/416), driven against a server that links THIS repo's# working-tree sigil-http (via a from-path bundle, same technique as# run-streaming-tests.sh — a loose `sigil <file>` would resolve the RELEASED# package from the dep cache and silently test old code).## Wire evidence is captured two ways:# - curl for connection reuse, headers, and Range status/sizes# - a raw Python socket for byte-exact chunked framing (0\r\n\r\n) and for# proving two serial requests are served on ONE connection.## Requires: curl, python3, a working `sigil` toolchain, network on first run.# test/integration/run-keepalive-range-tests.shset -uSCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"SERVER_SRC="$SCRIPT_DIR/keepalive-range-server-main.sgl"APP_DIR="$(mktemp -d /tmp/t4t2-app.XXXXXX)"BIN="$APP_DIR/build/dev/bin/t4t2-server"ASSET="$(mktemp /tmp/t4t2-asset.XXXXXX.bin)"PORT=18251SRV_PID=""FAILED=0cleanup() { [ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null rm -rf "$APP_DIR" "$ASSET"}trap cleanup EXITpass() { echo "PASS: $1"; }fail() { echo "FAIL: $1"; FAILED=1; }scaffold_app() { mkdir -p "$APP_DIR/src/t4t2-server" cp "$SERVER_SRC" "$APP_DIR/src/t4t2-server/main.sgl" cat > "$APP_DIR/package.sgl" <<EOF(package name: "t4t2-server" version: "0.1.0" sigil: "^0.17" description: "Ephemeral integration bundle for T4 keep-alive + T2 range" entry: '(t4t2-server main) bundle-name: "t4t2-server" configs: (list (config name: 'dev output-dir: "build/dev" static?: #f debug?: #t optimize: 0 bundle?: #t)) dependencies: (list (from-git url: "codeberg:sigil/sigil" package: "sigil-run" version: "^0.17") (from-git url: "codeberg:sigil/sigil" package: "sigil-stdlib" version: "^0.17") (from-git url: "codeberg:sigil/sigil-json" version: "^0.16") (from-path dir: "$REPO_ROOT" package: "sigil-http")))EOF}start_server() { "$BIN" "$PORT" "$ASSET" >"$APP_DIR/server.log" 2>&1 & SRV_PID=$! local i for i in $(seq 1 60); do if curl -s --max-time 2 "http://127.0.0.1:$PORT/hello" >/dev/null 2>&1; then return 0 fi if ! kill -0 "$SRV_PID" 2>/dev/null; then echo " server died on startup; log:"; cat "$APP_DIR/server.log" return 1 fi sleep 0.5 done echo " server never became ready"; return 1}echo "Scaffolding + building integration bundle (from-path sigil-http)..."scaffold_app( cd "$APP_DIR" && sigil deps install >/dev/null 2>&1 && sigil build >"$APP_DIR/build.log" 2>&1 )if [ ! -x "$BIN" ]; then echo "FAIL: bundle build did not produce $BIN" tail -20 "$APP_DIR/build.log" 2>/dev/null exit 1fi# 4 KB asset for range tests.head -c 4096 /dev/urandom > "$ASSET"ASSET_SIZE=$(stat -c%s "$ASSET")if ! start_server; then echo "Some integration tests FAILED (startup)."; exit 1fi# ---------------------------------------------------------------------------# T4.1 — keep-alive: two requests reuse ONE TCP connection (curl).# ---------------------------------------------------------------------------REUSE=$(curl -sv --http1.1 "http://127.0.0.1:$PORT/hello" "http://127.0.0.1:$PORT/hello" 2>&1 \ | grep -c 'Re-using existing connection')if [ "$REUSE" -ge 1 ]; then pass "T4 keep-alive: curl re-used the connection for a 2nd request"else fail "T4 keep-alive: curl did NOT re-use the connection (count=$REUSE)"fi# ---------------------------------------------------------------------------# T4.2 — response advertises Connection: keep-alive + Content-Length (HTTP/1.1).# ---------------------------------------------------------------------------H=$(curl -s --http1.1 -D - -o /dev/null "http://127.0.0.1:$PORT/hello")if echo "$H" | grep -qi '^connection: *keep-alive' && echo "$H" | grep -qi '^content-length:'; then pass "T4 keep-alive: Connection: keep-alive + Content-Length present"else fail "T4 keep-alive: missing keep-alive/Content-Length headers"; echo "$H" | sed 's/^/ /'fi# ---------------------------------------------------------------------------# T4.3 — explicit Connection: close is honored.# ---------------------------------------------------------------------------H=$(curl -s --http1.1 -H 'Connection: close' -D - -o /dev/null "http://127.0.0.1:$PORT/hello")if echo "$H" | grep -qi '^connection: *close'; then pass "T4 keep-alive: Connection: close honored"else fail "T4 keep-alive: Connection: close NOT honored"; echo "$H" | sed 's/^/ /'fi# ---------------------------------------------------------------------------# T4.4 — chunked framing over the wire (raw socket: exact bytes + terminator).# ---------------------------------------------------------------------------python3 - "$PORT" <<'PY'import socket, sysport = int(sys.argv[1])s = socket.create_connection(("127.0.0.1", port), timeout=5)s.sendall(b"GET /chunked HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")data = b""s.settimeout(5)try: while True: b = s.recv(4096) if not b: break data += bexcept socket.timeout: passs.close()head, _, body = data.partition(b"\r\n\r\n")ok = Trueif b"transfer-encoding: chunked" not in head.lower(): print(" raw: missing Transfer-Encoding: chunked header"); ok = Falseif b"content-length" in head.lower(): print(" raw: unexpected Content-Length on chunked response"); ok = False# Expect chunk sizes 7 (=0x7) for each 'chunk-X' and a terminator.if b"7\r\nchunk-A\r\n" not in body: print(" raw: chunk-A not framed as '7\\r\\nchunk-A\\r\\n'"); ok = Falseif not body.rstrip(b"\r\n").endswith(b"0") and not body.endswith(b"0\r\n\r\n"): print(" raw: missing terminating 0-chunk"); ok = Falseif b"0\r\n\r\n" not in body: print(" raw: no 0\\r\\n\\r\\n terminator in body"); ok = Falsesys.exit(0 if ok else 1)PYif [ $? -eq 0 ]; then pass "T4 chunked: valid chunk framing + terminating 0\\r\\n\\r\\n (raw socket)"else fail "T4 chunked: framing/terminator check failed"fi# ---------------------------------------------------------------------------# T4.5 — TWO serial requests served on ONE connection (raw socket, definitive).# ---------------------------------------------------------------------------python3 - "$PORT" <<'PY'import socket, sysport = int(sys.argv[1])def read_by_content_length(s): buf = b"" while b"\r\n\r\n" not in buf: b = s.recv(1) if not b: return None, b"" buf += b head, _, rest = buf.partition(b"\r\n\r\n") cl = 0 for line in head.split(b"\r\n"): if line.lower().startswith(b"content-length:"): cl = int(line.split(b":")[1].strip()) while len(rest) < cl: chunk = s.recv(cl - len(rest)) if not chunk: break rest += chunk return head, rests = socket.create_connection(("127.0.0.1", port), timeout=5)s.settimeout(5)req = b"GET /hello HTTP/1.1\r\nHost: x\r\n\r\n"s.sendall(req)h1, b1 = read_by_content_length(s)s.sendall(req) # SAME socket, second requesth2, b2 = read_by_content_length(s)s.close()ok = (h1 and h2 and b1 == b"Hello, World!" and b2 == b"Hello, World!" and b" 200 " in h1.split(b"\r\n")[0] and b" 200 " in h2.split(b"\r\n")[0])if not ok: print(" raw serial: b1=%r b2=%r" % (b1, b2))sys.exit(0 if ok else 1)PYif [ $? -eq 0 ]; then pass "T4 keep-alive: two serial requests served on one socket (raw, no reconnect)"else fail "T4 keep-alive: serial-reuse on one socket failed"fi# ---------------------------------------------------------------------------# T2.1 — Range bytes=0-99 -> 206 + Content-Range + 100 bytes.# ---------------------------------------------------------------------------DL="$(mktemp)"H=$(curl -s -r 0-99 -D - -o "$DL" "http://127.0.0.1:$PORT/file")CODE=$(echo "$H" | head -1 | grep -oE '[0-9]{3}')SZ=$(stat -c%s "$DL")if [ "$CODE" = "206" ] && echo "$H" | grep -qi "^content-range: *bytes 0-99/$ASSET_SIZE" && [ "$SZ" = "100" ]; then pass "T2 range: bytes=0-99 -> 206, Content-Range bytes 0-99/$ASSET_SIZE, 100 bytes"else fail "T2 range: bytes=0-99 (code=$CODE size=$SZ)"; echo "$H" | sed 's/^/ /'fi# T2.2 — suffix bytes=-500 -> 206 + last 500 bytes.H=$(curl -s -r -500 -D - -o "$DL" "http://127.0.0.1:$PORT/file")CODE=$(echo "$H" | head -1 | grep -oE '[0-9]{3}')SZ=$(stat -c%s "$DL")EXP_START=$((ASSET_SIZE - 500))if [ "$CODE" = "206" ] && echo "$H" | grep -qi "^content-range: *bytes $EXP_START-$((ASSET_SIZE-1))/$ASSET_SIZE" && [ "$SZ" = "500" ]; then pass "T2 range: suffix bytes=-500 -> 206, last 500 bytes"else fail "T2 range: suffix bytes=-500 (code=$CODE size=$SZ)"; echo "$H" | sed 's/^/ /'fi# T2.3 — open-ended bytes=500- -> 206 remainder.H=$(curl -s -r 500- -D - -o "$DL" "http://127.0.0.1:$PORT/file")CODE=$(echo "$H" | head -1 | grep -oE '[0-9]{3}')SZ=$(stat -c%s "$DL")if [ "$CODE" = "206" ] && [ "$SZ" = "$((ASSET_SIZE-500))" ]; then pass "T2 range: open-ended bytes=500- -> 206, $((ASSET_SIZE-500)) bytes"else fail "T2 range: open-ended bytes=500- (code=$CODE size=$SZ)"fi# T2.4 — unsatisfiable -> 416 + Content-Range: bytes */total.H=$(curl -s -H "Range: bytes=$((ASSET_SIZE+1000))-" -D - -o /dev/null "http://127.0.0.1:$PORT/file")CODE=$(echo "$H" | head -1 | grep -oE '[0-9]{3}')if [ "$CODE" = "416" ] && echo "$H" | grep -qi "^content-range: *bytes \*/$ASSET_SIZE"; then pass "T2 range: unsatisfiable -> 416, Content-Range bytes */$ASSET_SIZE"else fail "T2 range: unsatisfiable (code=$CODE)"; echo "$H" | sed 's/^/ /'fi# T2.5 — no Range -> 200, full body byte-exact.curl -s -D - -o "$DL" "http://127.0.0.1:$PORT/file" >"$APP_DIR/full-head.txt"if head -1 "$APP_DIR/full-head.txt" | grep -q ' 200 ' && cmp -s "$ASSET" "$DL"; then pass "T2 range: no Range -> 200, byte-exact full file ($ASSET_SIZE bytes)"else fail "T2 range: full-file 200 mismatch"firm -f "$DL"echo ""if [ "$FAILED" -eq 0 ]; then echo "All keep-alive + range integration tests passed." exit 0else echo "Some keep-alive + range integration tests FAILED." echo "--- server log ---"; cat "$APP_DIR/server.log" exit 1fitest/test-response.sglmodified
(sigil async) (sigil channels) (sigil time) (sigil fs) (sigil http response));; ============================================================ (assert-false (string-contains? output "chunk1")) (assert-true (string-starts-with? output "HTTP/1.1 200 OK"))))));; ============================================================;; T2: Range / 206 / 416 handling;; ============================================================(test-group "parse-range-header" (test "absolute range bytes=0-499" (assert-equal (parse-range-header "bytes=0-499") (cons 0 499))) (test "open-ended range bytes=500-" (assert-equal (parse-range-header "bytes=500-") (cons 500 #f))) (test "suffix range bytes=-500 => (#f . 500)" (assert-equal (parse-range-header "bytes=-500") (cons #f 500))) (test "absent header => #f" (assert-false (parse-range-header #f))) (test "malformed (no '=') => #f" (assert-false (parse-range-header "bytes 0-99"))) (test "malformed (bare dash) => #f" (assert-false (parse-range-header "bytes=-"))))(test-group "resolve-range" (test "absolute in-bounds" (assert-equal (resolve-range (cons 0 99) 1000) (cons 0 99))) (test "open-ended clamps to last byte" (assert-equal (resolve-range (cons 500 #f) 1000) (cons 500 999))) (test "end past EOF is clamped" (assert-equal (resolve-range (cons 900 5000) 1000) (cons 900 999))) (test "suffix returns the last N bytes" (assert-equal (resolve-range (cons #f 500) 1000) (cons 500 999))) (test "suffix larger than file yields whole file" (assert-equal (resolve-range (cons #f 5000) 1000) (cons 0 999))) (test "start at/after EOF is unsatisfiable" (assert-equal (resolve-range (cons 1000 #f) 1000) 'unsatisfiable) (assert-equal (resolve-range (cons 2000 3000) 1000) 'unsatisfiable)) (test "empty file: any range unsatisfiable" (assert-equal (resolve-range (cons 0 0) 0) 'unsatisfiable) (assert-equal (resolve-range (cons #f 10) 0) 'unsatisfiable)));; http-response/file end-to-end range behavior against a real 1000-byte file.(define range-test-path "/tmp/sigil-http-range-test.bin")(define range-test-size 1000)(define range-test-bytes (let ((bv (make-bytevector range-test-size 0))) (let loop ((i 0)) (if (>= i range-test-size) bv (begin (bytevector-u8-set! bv i (modulo i 256)) (loop (+ i 1)))))))(write-file-bytes range-test-path range-test-bytes);; Drive a streaming file-response body to completion, returning its bytes.(define (collect-file-body res) (let ((chunks '())) ((http-response-body res) (lambda (data) (set! chunks (cons (if (string? data) (string->utf8 data) data) chunks)) #t) (lambda () #t)) (if (null? chunks) (make-bytevector 0) (apply bytevector-append (reverse chunks)))))(define (res-header res key) (dict-ref (http-response-headers res) key #f))(test-group "http-response/file range wiring" (test "bytes=0-99 -> 206 + Content-Range + 100 bytes" (let ((res (http-response/file range-test-path range: "bytes=0-99"))) (assert-equal (http-response-status res) 206) (assert-equal (res-header res content-range:) "bytes 0-99/1000") (assert-equal (res-header res content-length:) "100") (assert-equal (res-header res accept-ranges:) "bytes") (let ((body (collect-file-body res))) (assert-equal (bytevector-length body) 100) (assert-equal (bytevector-copy body 0 100) (bytevector-copy range-test-bytes 0 100))))) (test "open-ended bytes=500- -> 206, last 500 bytes" (let ((res (http-response/file range-test-path range: "bytes=500-"))) (assert-equal (http-response-status res) 206) (assert-equal (res-header res content-range:) "bytes 500-999/1000") (assert-equal (res-header res content-length:) "500") (let ((body (collect-file-body res))) (assert-equal (bytevector-length body) 500) (assert-equal body (bytevector-copy range-test-bytes 500 1000))))) (test "suffix bytes=-500 -> 206, final 500 bytes" (let ((res (http-response/file range-test-path range: "bytes=-500"))) (assert-equal (http-response-status res) 206) (assert-equal (res-header res content-range:) "bytes 500-999/1000") (assert-equal (res-header res content-length:) "500") (let ((body (collect-file-body res))) (assert-equal (bytevector-length body) 500) (assert-equal body (bytevector-copy range-test-bytes 500 1000))))) (test "unsatisfiable range -> 416 + Content-Range: bytes */total" (let ((res (http-response/file range-test-path range: "bytes=2000-3000"))) (assert-equal (http-response-status res) 416) (assert-equal (res-header res content-range:) "bytes */1000"))) (test "absent Range -> 200 full body" (let ((res (http-response/file range-test-path))) (assert-equal (http-response-status res) 200) (assert-equal (res-header res content-length:) "1000") (assert-equal (res-header res accept-ranges:) "bytes") (assert-equal (bytevector-length (collect-file-body res)) 1000))) (test "malformed Range -> 200 full body" (let ((res (http-response/file range-test-path range: "not-a-range"))) (assert-equal (http-response-status res) 200) (assert-equal (res-header res content-length:) "1000"))));; ============================================================;; T4: Chunked transfer-encoding framing;; ============================================================;; Collect the full wire output of write-http-response into one string.(define (capture-response res . kw) (let ((out '())) (define (w sock data) (set! out (cons (if (string? data) data (utf8->string data)) out)) (if (string? data) (string-length data) (bytevector-length data))) (apply write-http-response res 'sock w kw) (apply string-append (reverse out))))(test-group "chunked framing" (test "streaming body without length -> Transfer-Encoding: chunked + terminator" (let* ((res (http-response status: 200 headers: #{ content-type: "text/plain" } body: (lambda (emit finish) (emit "hello") (emit " world") (finish)))) (out (capture-response res chunked: #t))) ;; Header advertises chunked, and NO Content-Length is present. (assert-true (string-contains? out "transfer-encoding: chunked")) (assert-false (string-contains? out "content-length:")) ;; Each write is framed <hexlen>\r\n<data>\r\n ... (assert-true (string-contains? out "5\r\nhello\r\n")) (assert-true (string-contains? out "6\r\n world\r\n")) ;; ...and the stream ends with the zero-length terminator. (assert-true (string-ends-with? out "0\r\n\r\n")))) (test "chunked skips empty writes (no premature terminator)" (let* ((res (http-response status: 200 headers: #{ content-type: "text/plain" } body: (lambda (emit finish) (emit "") ; must be skipped, not framed as 0 (emit "data") (finish)))) (out (capture-response res chunked: #t))) (assert-true (string-contains? out "4\r\ndata\r\n")) ;; Only ONE terminator, at the very end. (assert-true (string-ends-with? out "0\r\n\r\n")))) (test "keep-alive: sets Connection: keep-alive" (let* ((res (http-response/text 200 "hi")) (out (capture-response res keep-alive: #t))) (assert-true (string-contains? out "connection: keep-alive")) (assert-true (string-contains? out "content-length: 2")))) (test "default (no keep-alive) still Connection: close" (let* ((res (http-response/text 200 "hi")) (out (capture-response res))) (assert-true (string-contains? out "connection: close")))));; ============================================================;; SSE Heartbeat Tests;; ============================================================