Add HEAD headers-only (T3) and opt-in gzip (T11); server-loop cleanup
T3 — HEAD returns headers only: - write-http-response gains head?: which writes the status line and headers (ensure-headers still computes Content-Length) but suppresses the body, including not invoking a streaming producer. - handle-request answers HEAD headers-only for every route (streaming bodies too) and never spawns a streaming goroutine for HEAD.
T11 — opt-in automatic gzip: - make-http-server/http-serve gain gzip: (default #f). When enabled, handle-request runs the existing http-response-gzip over non-streaming responses per the request Accept-Encoding. Off by default, so the default server is byte-for-byte unchanged; streaming/incompressible/small bodies are never compressed.
Cleanup (from the T1 review): - http-server-start returns the same value (the final server) on both the reuse and self-install paths instead of leaking with-async's status symbol. - server-loop drops the now-unreachable socket-select blocking branch (a scheduler is always established before it runs; await-readable still degrades gracefully if ever run without one).
Tests: write-http-response head? unit tests; the integration driver now also checks HEAD (0 body bytes + Content-Length over the wire) and gzip (Accept-Encoding negotiation, Vary, smaller body, off-by-default, streaming file stays byte-exact).
src/sigil/http/response.sgl | 11 +++++++++--
src/sigil/http/server.sgl | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------------
test/integration/run-streaming-tests.sh | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/integration/streaming-server-main.sgl | 36 ++++++++++++++++++++++++++++--------
test/test-response.sgl | 37 +++++++++++++++++++++++++++++++++++++
5 files changed, 227 insertions(+), 61 deletions(-)src/sigil/http/response.sglmodified
;;; ;;; The write function should accept `(socket data)` and return bytes ;;; written or `#f`. Returns `#t` on success, `#f` on error. (define (write-http-response res sock write-fn) (: http-response? any? procedure? -> boolean?) ;;; ;;; When `head?:` is #t (a response to a HEAD request), the status line and ;;; headers are written but the body is suppressed entirely — including not ;;; 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?) (let* ((status (http-response-status res)) (body (http-response-body res)) (headers (ensure-headers (http-response-headers res) body)) (write-fn sock headers-str) (write-fn sock "\r\n") (cond (head? #t) ; HEAD: headers only, no body bytes ((not body) #t) ((string? body) (if (write-fn sock body) #t #f))src/sigil/http/server.sglmodified
(backlog default: 128) ; Listen backlog (timeout default: 30000) ; Request timeout (ms) (max-request-size default: (* 10 1024 1024)) ; 10MB (gzip default: #f) ; Opt-in automatic gzip (Accept-Encoding) (socket default: #f) ; Listening socket (running default: #f) ; Running state (clients default: '())) ; Active client connections ;;; - `backlog:` - Listen queue size (default: 128) ;;; - `timeout:` - Request timeout in milliseconds (default: 30000) ;;; - `max-request-size:` - Maximum request body size in bytes (default: 10MB) ;;; - `gzip:` - Opt-in automatic gzip. When #t, non-streaming responses are ;;; gzip-encoded per the request's `Accept-Encoding` (only when the client ;;; accepts gzip, the type is compressible, and the body is large enough). ;;; Default #f — the default server behaves exactly as before. (define (make-http-server handler (keys: (port 8080) (host "0.0.0.0") (backlog 128) (timeout 30000) (max-request-size (* 10 1024 1024)))) (: procedure? (port: integer?) (host: string?) (backlog: integer?) (timeout: integer?) (max-request-size: integer?) -> http-server?) (max-request-size (* 10 1024 1024)) (gzip #f))) (: procedure? (port: integer?) (host: string?) (backlog: integer?) (timeout: integer?) (max-request-size: integer?) (gzip: any?) -> http-server?) (http-server handler: handler port: port host: host backlog: backlog timeout: timeout max-request-size: max-request-size)) max-request-size: max-request-size gzip: gzip)) ;;; Check if server is currently running. ;;; ;; one, so streaming responses (which spawn `go` goroutines) ;; work whether or not the server was started inside ;; `with-async`. When a scheduler is already current, reuse it. (if (current-scheduler) (run-server-resume-loop server*) (with-async (run-server-resume-loop server*)))))))) ;; Capture the loop's result in both paths so the return value ;; is the same (the final/stopped server) regardless of which ;; branch ran — `with-async` itself would otherwise yield the ;; scheduler-run status symbol instead. (let ((final #f)) (if (current-scheduler) (set! final (run-server-resume-loop server*)) (with-async (set! final (run-server-resume-loop server*)))) final)))))) ;;; Run the guarded server loop, restarting on any escaping exception. ;;; Assumes an async scheduler is already active (see http-server-start). ;;; Main server loop ;;; ;;; When running inside an async scheduler, cooperates with other tasks ;;; (including streaming-response goroutines) via `await-readable`. ;;; ;;; Since http-server-start now always establishes a scheduler before ;;; entering the loop (reusing the caller's, or self-installing one via ;;; `with-async`), the cooperative branch is the path taken in practice. ;;; The socket-select blocking branch is retained as a defensive fallback ;;; for any direct/manual invocation of server-loop without a scheduler. ;;; Cooperates with other tasks (including streaming-response goroutines) ;;; via `await-readable`, which yields to the async scheduler that ;;; http-server-start always establishes before entering the loop (it ;;; reuses the caller's scheduler, or self-installs one via `with-async`). ;;; `await-readable`/`process-connections` degrade to a blocking ;;; `socket-select` on their own if ever run without a scheduler, so the ;;; loop stays correct either way. (define (server-loop server) (if (not (http-server-running server)) server (if (current-scheduler) ;; Cooperative mode: yield to scheduler while waiting for I/O (let loop ((server server)) (if (not (http-server-running server)) server (begin (await-readable (http-server-socket server)) ;; Process pending connections with guard to prevent ;; a single bad connection from crashing the server loop (let ((server* (guard (exn (else (log-server-error "Exception in connection processing" exn) server)) (process-connections server 0)))) (let drain ((s server*)) (if (null? (http-server-clients s)) ;; No more clients, wait for next connection (loop s) ;; More clients - quick poll then continue (drain (guard (exn (else (log-server-error "Exception in drain loop" exn) s)) (process-connections s 10))))))))) ;; Blocking mode: traditional socket-select loop (let ((server* (process-connections server 100))) (server-loop server*))))) (let loop ((server server)) (if (not (http-server-running server)) server (begin (await-readable (http-server-socket server)) ;; Process pending connections with guard to prevent ;; a single bad connection from crashing the server loop (let ((server* (guard (exn (else (log-server-error "Exception in connection processing" exn) server)) (process-connections server 0)))) (let drain ((s server*)) (if (null? (http-server-clients s)) ;; No more clients, wait for next connection (loop s) ;; More clients - quick poll then continue (drain (guard (exn (else (log-server-error "Exception in drain loop" exn) s)) (process-connections s 10))))))))))) ;;; Process connections using socket-select (define (process-connections server timeout-ms) (if (not response) ;; Handler returned #f - send 404 (set! response (http-response/not-found))) ;; Check if this is a streaming response (SSE, chunked, etc.) (let ((body (http-response-body response))) (if (procedure? body) ;; Opt-in automatic gzip (Accept-Encoding). http-response-gzip is a ;; no-op unless the client accepts gzip and the body is a compressible ;; string/bytevector over the size floor, so streaming bodies and the ;; gzip-disabled default are untouched. (when (http-server-gzip server) (set! response (http-response-gzip (http-request-header request "Accept-Encoding") response))) ;; HEAD must return identical status + headers but zero body bytes. ;; It never streams: even a streaming body is answered headers-only, ;; and ensure-headers still advertises the correct Content-Length. (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 (go (guard (exn (send-response sock response) (socket-close sock))) #f) ; Remove from normal client list (goroutine owns socket now) ;; Normal response - send and close ;; Normal response (or HEAD) - send and close (begin (send-response sock response) (send-response sock response head?: head?) (socket-close sock) #f)))))) ; Remove client from list ;;; 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) (define (send-response sock response (keys: (head? #f))) (write-http-response response sock (lambda (s data) (let* ((bv (if (string? data) (string->utf8 data) data)) (begin (await-writable s) (loop offset (+ attempts 1))) #f))))))))) #f))))))) head?: head?)) ;;; Send an error response (define (send-error-response sock status message) ;;; If you already run inside `with-async` (e.g. you spawn the server with ;;; `(go (http-server-start …))` alongside other goroutines), that ;;; scheduler is reused — no nested scheduler is created. ;;; ;;; Pass `gzip: #t` to opt into automatic gzip of non-streaming responses ;;; per the request's `Accept-Encoding` (off by default). (define (http-serve handler (keys: (port 8080) (host "0.0.0.0") (backlog 128) (timeout 30000) (max-request-size (* 10 1024 1024)))) (: procedure? (port: integer?) (host: string?) (backlog: integer?) (timeout: integer?) (max-request-size: integer?) -> any?) (max-request-size (* 10 1024 1024)) (gzip #f))) (: procedure? (port: integer?) (host: string?) (backlog: integer?) (timeout: integer?) (max-request-size: integer?) (gzip: any?) -> any?) (http-server-start (make-http-server handler port: port host: host backlog: backlog timeout: timeout max-request-size: max-request-size))) max-request-size: max-request-size gzip: gzip))) ))test/integration/run-streaming-tests.shmodified
SSE="$(mktemp /tmp/t1-sse.XXXXXX.txt)"BARE_PORT=18201WRAPPED_PORT=18202GZIP_PORT=18203SRV_PID=""FAILED=0 pass "$mode: no async-context error in server log" fi # 4. HEAD returns headers (incl. Content-Length) and ZERO body bytes. # curl -I masks the old HEAD-body bug (it closes after headers), so we # save the body to a file and assert it is empty. See review gotchas. local hb hh hbsize hb="$(mktemp)"; hh="$(mktemp)" curl -s --max-time 5 -X HEAD -D "$hh" -o "$hb" "http://127.0.0.1:$port/big-text" hbsize=$(stat -c%s "$hb" 2>/dev/null || echo -1) if [ "$hbsize" = "0" ] \ && head -1 "$hh" | grep -q " 200 " \ && grep -qi '^content-length:' "$hh"; then pass "$mode: HEAD /big-text -> headers only (0 body bytes, Content-Length present)" else fail "$mode: HEAD expected 0 body bytes + status 200 + Content-Length (got $hbsize body bytes)" fi rm -f "$hb" "$hh" # 5. gzip is OFF by default: even with Accept-Encoding: gzip, no encoding. local h5 h5="$(curl -s --max-time 5 -H 'Accept-Encoding: gzip' -D - -o /dev/null "http://127.0.0.1:$port/big-text")" if echo "$h5" | grep -qi '^content-encoding:'; then fail "$mode: gzip disabled by default but response was content-encoded" else pass "$mode: gzip off by default (no Content-Encoding even when client accepts gzip)" fi stop_server}# gzip-enabled server (T11): opt-in gzip honoring Accept-Encoding.run_gzip() { local port="$1" echo "" echo "=== gzip mode (port $port) ===" if ! start_server "$port" "gzip"; then fail "gzip: server startup" stop_server return fi # Baseline: identity size (no Accept-Encoding). local raw_size gz_size hdr curl -s --max-time 5 -o "$DL" "http://127.0.0.1:$port/big-text" raw_size=$(stat -c%s "$DL" 2>/dev/null || echo 0) # With Accept-Encoding: gzip -> Content-Encoding: gzip and a smaller body. hdr="$(curl -s --max-time 5 -H 'Accept-Encoding: gzip' -D - -o "$DL" "http://127.0.0.1:$port/big-text")" gz_size=$(stat -c%s "$DL" 2>/dev/null || echo 0) if echo "$hdr" | grep -qi '^content-encoding: *gzip' \ && echo "$hdr" | grep -qi '^vary: *Accept-Encoding'; then pass "gzip: Accept-Encoding: gzip -> Content-Encoding: gzip + Vary" else fail "gzip: expected Content-Encoding: gzip and Vary: Accept-Encoding" fi if [ "$gz_size" -gt 0 ] && [ "$gz_size" -lt "$raw_size" ]; then pass "gzip: compressed body smaller than identity ($gz_size < $raw_size bytes)" else fail "gzip: compressed body not smaller ($gz_size vs $raw_size bytes)" fi # Without Accept-Encoding -> identity (no Content-Encoding). hdr="$(curl -s --max-time 5 -D - -o /dev/null "http://127.0.0.1:$port/big-text")" if echo "$hdr" | grep -qi '^content-encoding:'; then fail "gzip: response without Accept-Encoding must not be gzipped" else pass "gzip: no Accept-Encoding -> identity" fi # Incompressible/streamed file must never be gzipped, even when enabled. hdr="$(curl -s --max-time 15 -H 'Accept-Encoding: gzip' -D - -o "$DL" "http://127.0.0.1:$port/file")" if echo "$hdr" | grep -qi '^content-encoding:' || ! cmp -s "$ASSET" "$DL"; then fail "gzip: streaming file was altered/encoded (must stay byte-exact identity)" else pass "gzip: streaming file stays byte-exact identity (not gzipped)" fi stop_server}run_mode "bare" "$BARE_PORT" # T1: streaming works WITHOUT with-asyncrun_mode "wrapped" "$WRAPPED_PORT" # regression: existing with-async consumersrun_gzip "$GZIP_PORT" # T11: opt-in gzip honoring Accept-Encodingecho ""if [ "$FAILED" -eq 0 ]; thentest/integration/streaming-server-main.sglmodified
;; Asset path captured from argv at startup. (define *asset-path* (make-parameter "/dev/null")) ;; A compressible text body comfortably over the gzip size floor (1 KB). (define big-text (let loop ((n 0) (acc "")) (if (>= n 200) acc (loop (+ n 1) (string-append acc "The quick brown fox jumps over the lazy dog. "))))) (define (handler request) (let ((path (http-request-path request))) (cond ((string=? path "/hello") (http-response/text HTTP-OK "Hello, World!")) ;; Large compressible text — exercises opt-in gzip (T11). ((string=? path "/big-text") (http-response/text HTTP-OK big-text)) ;; Streaming file — the "image sometimes doesn't download" path. ((string=? path "/file") (http-response/file (*asset-path*))) (close)))))) (else (http-response/not-found))))) ;;; Modes: ;;; bare -> (http-serve ...) no with-async (T1 fix case; default) ;;; wrapped -> (with-async (go (http-server-start ...))) consumer pattern ;;; gzip -> bare + gzip: #t (opt-in gzip; T11) (define (main) (let* ((args (cdr (command-line))) (port (string->number (list-ref args 0))) (asset (list-ref args 1)) (mode (if (>= (length args) 3) (list-ref args 2) "bare"))) (*asset-path* asset) (if (string=? mode "wrapped") ;; Existing-consumer regression path: the server runs as a ;; goroutine inside a caller-owned scheduler. http-server-start ;; must reuse that scheduler rather than nest a second one. (let ((server (make-http-server handler port: port host: "127.0.0.1"))) (with-async (go (http-server-start server)))) ;; Bare path: no with-async at the call site (the T1 fix case). (http-serve handler port: port host: "127.0.0.1")))))) (cond ((string=? mode "wrapped") ;; Existing-consumer regression path: the server runs as a ;; goroutine inside a caller-owned scheduler. http-server-start ;; must reuse that scheduler rather than nest a second one. (let ((server (make-http-server handler port: port host: "127.0.0.1"))) (with-async (go (http-server-start server))))) ((string=? mode "gzip") ;; Opt-in gzip enabled (T11). Still a bare call (T1 fix applies). (http-serve handler port: port host: "127.0.0.1" gzip: #t)) (else ;; Bare path: no with-async at the call site (the T1 fix case). (http-serve handler port: port host: "127.0.0.1")))))))test/test-response.sglmodified
(assert-true (string-contains? output "chunk1")) (assert-true (string-contains? output "chunk2"))))));; T3: HEAD returns identical status + headers but zero body bytes.(test "write-http-response head?: suppresses the body but keeps headers" (let ((written '())) (define (mock-write sock data) (set! written (cons data written)) (string-length data)) (let ((res (http-response/text 200 "Test body"))) (write-http-response res 'mock-socket mock-write head?: #t) (let ((output (apply string-append (reverse written)))) ;; Status line + headers present, incl. the GET Content-Length... (assert-true (string-starts-with? output "HTTP/1.1 200 OK")) (assert-true (string-contains? output "content-length: 9")) ;; ...but the body itself must NOT be on the wire. (assert-false (string-contains? output "Test body")) ;; And nothing follows the header terminator. (assert-true (string-ends-with? output "\r\n\r\n"))))));; T3: HEAD must not invoke a streaming producer at all.(test "write-http-response head?: does not run a streaming body" (let ((written '()) (produced #f)) (define (mock-write sock data) (set! written (cons data written)) (string-length data)) (let ((res (http-response status: 200 headers: #{ content-type: "text/plain" } body: (lambda (emit-chunk finish) (set! produced #t) (emit-chunk "chunk1") (finish))))) (write-http-response res 'mock-socket mock-write head?: #t) (let ((output (apply string-append (reverse written)))) (assert-false produced) ; producer never called (assert-false (string-contains? output "chunk1")) (assert-true (string-starts-with? output "HTTP/1.1 200 OK"))))));; ============================================================;; SSE Heartbeat Tests;; ============================================================