Commitd77d71e2Recorded10 Jul 2026Repositorysigil-http

Add keep-alive + chunked responses (T4) and Range/206 (T2)

Message

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.

Changed
 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(-)
Diff
CHANGELOG.mdmodified
@@ -5,6 +5,37 @@ All notable changes to **sigil-http** are documented in this file.
5
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+8
## [Unreleased]
+9
+10
### Added
+11
+12
- **Persistent connections (keep-alive).** Non-streaming HTTP/1.1 responses now
+13
keep the TCP connection open and serve subsequent requests on the same socket
+14
instead of forcing `Connection: close` after every response. The server honors
+15
an explicit `Connection: close` and defaults HTTP/1.0 to close; the age-based
+16
timeout is refreshed per request so it acts as an idle timeout for kept-alive
+17
connections. This removes the connection-churn that amplified image-heavy page
+18
loads.
+19
- **Chunked response transfer-encoding.** Streaming responses whose length is
+20
unknown (SSE and bare procedure bodies) are now framed with
+21
`Transfer-Encoding: chunked` and a terminating `0\r\n\r\n`, instead of relying
+22
on connection close for framing. `http-response/file` keeps its
+23
`Content-Length` framing (byte-exact).
+24
- **`Range:` requests and `206 Partial Content`.** `http-response/file` accepts
+25
a `range:` keyword (the raw request `Range` header) and honors byte ranges:
+26
`bytes=A-B`, open-ended `bytes=A-`, and suffix `bytes=-N`. Satisfiable ranges
+27
yield `206` with `Content-Range`; unsatisfiable ranges yield `416 Range Not
+28
Satisfiable` with `Content-Range: bytes */<total>`; an absent or malformed
+29
header falls back to a full `200`. Adds the `HTTP-RANGE-NOT-SATISFIABLE`
+30
constant and exports `resolve-range`.
+31
+32
### Notes
+33
+34
- Reusing a **streamed** connection for keep-alive is not yet supported — a
+35
streaming response still closes when the stream ends (the select loop would
+36
need to re-adopt the goroutine-owned socket). HTTP request pipelining is also
+37
unsupported. Both are tracked follow-ups.
+38
39
## [0.17.0] - 2026-07-10
40
41
### Changed
docs/http.mdmodified
@@ -134,6 +134,34 @@ Start a blocking HTTP server.
134
body: (json-encode #{ id: 123 }))
135
```
136
+137
### File Serving and Range Requests
+138
+139
`http-response/file` streams a file from disk with an auto-detected MIME type.
+140
Pass `range:` the request's raw `Range` header to honor byte ranges — media
+141
seeking and resumable downloads:
+142
+143
```scheme
+144
;; Whole file (200 OK), advertises Accept-Ranges: bytes
+145
(http-response/file "/srv/video.mp4")
+146
+147
;; Honor the request Range header: 206 Partial Content for a satisfiable
+148
;; range, 416 Range Not Satisfiable otherwise, 200 when there is no Range.
+149
(http-response/file "/srv/video.mp4"
+150
range: (http-request-header req "Range"))
+151
```
+152
+153
Supported range forms: `bytes=A-B` (first–last), `bytes=A-` (open-ended), and
+154
`bytes=-N` (the last N bytes). A `206` response carries `Content-Range` and the
+155
exact `Content-Length`; a `416` carries `Content-Range: bytes */<total>`.
+156
+157
### Persistent Connections (keep-alive)
+158
+159
Non-streaming HTTP/1.1 responses keep the connection open and serve subsequent
+160
requests on the same socket. The server honors an explicit `Connection: close`
+161
and closes HTTP/1.0 connections by default — no configuration required.
+162
Streaming responses of unknown length (SSE, procedure bodies) are framed with
+163
`Transfer-Encoding: chunked`.
+164
165
### Form Parsing
166
167
```scheme
@@ -165,10 +193,12 @@ Start a blocking HTTP server.
193
HTTP-OK ; 200
194
HTTP-CREATED ; 201
195
HTTP-NO-CONTENT ; 204
+196
HTTP-PARTIAL-CONTENT ; 206
197
HTTP-MOVED-PERMANENTLY ; 301
198
HTTP-FOUND ; 302
199
HTTP-NOT-MODIFIED ; 304
200
HTTP-BAD-REQUEST ; 400
+201
HTTP-RANGE-NOT-SATISFIABLE ; 416
202
HTTP-UNAUTHORIZED ; 401
203
HTTP-FORBIDDEN ; 403
204
HTTP-NOT-FOUND ; 404
src/sigil/http/response.sglmodified
@@ -48,6 +48,7 @@
48
HTTP-PAYLOAD-TOO-LARGE
49
HTTP-URI-TOO-LONG
50
HTTP-UNSUPPORTED-MEDIA-TYPE
+51
HTTP-RANGE-NOT-SATISFIABLE
52
HTTP-INTERNAL-SERVER-ERROR
53
HTTP-NOT-IMPLEMENTED
54
HTTP-BAD-GATEWAY
@@ -68,6 +69,7 @@
69
;; File serving
70
http-response/file
71
parse-range-header
+72
resolve-range
73
74
;; Compression
75
response-compressible?
@@ -118,6 +120,7 @@
120
(define HTTP-PAYLOAD-TOO-LARGE 413)
121
(define HTTP-URI-TOO-LONG 414)
122
(define HTTP-UNSUPPORTED-MEDIA-TYPE 415)
+123
(define HTTP-RANGE-NOT-SATISFIABLE 416)
124
(define HTTP-INTERNAL-SERVER-ERROR 500)
125
(define HTTP-NOT-IMPLEMENTED 501)
126
(define HTTP-BAD-GATEWAY 502)
@@ -158,6 +161,7 @@
161
((= code 413) "Payload Too Large")
162
((= code 414) "URI Too Long")
163
((= code 415) "Unsupported Media Type")
+164
((= code 416) "Range Not Satisfiable")
165
((= code 500) "Internal Server Error")
166
((= code 501) "Not Implemented")
167
((= code 502) "Bad Gateway")
@@ -347,24 +351,54 @@
351
(cdr entry)
352
"\r\n"))))))
353
350
;;; Ensure required headers are present
351
(define (ensure-headers headers body)
+354
;;; Ensure required framing headers are present.
+355
;;;
+356
;;; `keep-alive` selects the default `Connection` value (only applied when
+357
;;; the response didn't already set one — e.g. SSE sets `keep-alive`).
+358
;;; `chunked` requests `Transfer-Encoding: chunked` framing for a
+359
;;; streaming body of unknown length: it adds that header and suppresses
+360
;;; the `Content-Length` (the two are mutually exclusive per RFC 7230).
+361
;;;
+362
;;; Defaults (`keep-alive` #f, `chunked` #f) reproduce the historical
+363
;;; behavior: `Connection: close` and a `Content-Length` when the length
+364
;;; is known — so non-server callers are unaffected.
+365
(define (ensure-headers headers body (keys: (keep-alive #f) (chunked #f)))
366
(let* ((has-content-type (dict-ref headers content-type: #f))
367
(has-content-length (dict-ref headers content-length: #f))
368
(has-connection (dict-ref headers connection: #f))
+369
(has-transfer-encoding (dict-ref headers transfer-encoding: #f))
370
(len (body-length body))
371
(result headers))
357
;; Add Content-Length if body has known length
358
(when (and len (not has-content-length) (not (streaming-body? body)))
359
(set! result (dict-set result content-length: (number->string len))))
+372
(if (and chunked (not has-content-length))
+373
;; Chunked framing: advertise it and do NOT emit Content-Length.
+374
(when (not has-transfer-encoding)
+375
(set! result (dict-set result transfer-encoding: "chunked")))
+376
;; Otherwise add Content-Length when the length is known.
+377
(when (and len (not has-content-length) (not (streaming-body? body)))
+378
(set! result (dict-set result content-length: (number->string len)))))
379
;; Add default Content-Type if body present but no type
380
(when (and body (not has-content-type))
381
(set! result (dict-set result content-type: "application/octet-stream")))
363
;; Add Connection: close if not already set (SSE sets keep-alive)
+382
;; Default Connection unless the response set one explicitly.
383
(when (not has-connection)
365
(set! result (dict-set result connection: "close")))
+384
(set! result (dict-set result connection:
+385
(if keep-alive "keep-alive" "close"))))
386
result))
387
+388
;; Frame one already-materialized chunk (a bytevector) for
+389
;; Transfer-Encoding: chunked: <hex-length>\r\n<bytes>\r\n
+390
;; The length is the byte count, so it is correct for multi-byte text.
+391
;; Callers must not frame an empty payload (a zero-length chunk is the
+392
;; terminator).
+393
(define (frame-chunk bv)
+394
(let ((header (string->utf8
+395
(string-append (number->string (bytevector-length bv) 16)
+396
"\r\n"))))
+397
(bytevector-append header bv (string->utf8 "\r\n"))))
+398
+399
;; The terminating zero-length chunk that ends a chunked body.
+400
(define chunked-terminator "0\r\n\r\n")
+401
402
;;; Write an HTTP response to a socket.
403
;;;
404
;;; The write function should accept `(socket data)` and return bytes
@@ -375,11 +409,20 @@
409
;;; invoking a streaming producer. `ensure-headers` still computes the
410
;;; Content-Length from the body, so a HEAD response advertises the same
411
;;; headers a GET would while sending zero body bytes.
378
(define (write-http-response res sock write-fn (keys: (head? #f)))
379
(: http-response? any? procedure? (head?: boolean?) -> boolean?)
+412
;;;
+413
;;; `keep-alive:` sets the default `Connection` header (see `ensure-headers`).
+414
;;; `chunked:` frames a streaming (procedure) body with
+415
;;; `Transfer-Encoding: chunked` — each producer write becomes a chunk and
+416
;;; a terminating `0\r\n\r\n` is emitted when the producer returns. It only
+417
;;; affects procedure bodies; string/bytevector bodies always use
+418
;;; Content-Length framing.
+419
(define (write-http-response res sock write-fn
+420
(keys: (head? #f) (keep-alive #f) (chunked #f)))
+421
(: http-response? any? procedure? (head?: boolean?) (keep-alive: boolean?) (chunked: boolean?) -> boolean?)
422
(let* ((status (http-response-status res))
423
(body (http-response-body res))
382
(headers (ensure-headers (http-response-headers res) body))
+424
(headers (ensure-headers (http-response-headers res) body
+425
keep-alive: keep-alive chunked: chunked))
426
(status-line (build-status-line status))
427
(headers-str (build-headers-string headers)))
428
;; Write status line, headers, blank line, then body
@@ -394,17 +437,32 @@
437
((bytevector? body)
438
(if (write-fn sock body) #t #f))
439
((procedure? body)
397
;; Streaming body - call producer with write/close callbacks
+440
;; Streaming body - call producer with write/close callbacks.
+441
;; When chunked, each non-empty write is chunk-framed and a
+442
;; terminating zero chunk is sent after the producer returns.
443
(let ((closed #f))
444
(body
445
;; write callback
446
(lambda (data)
447
(if closed
448
#f
404
(if (write-fn sock data) #t #f)))
+449
(if chunked
+450
;; Materialize once, then skip empty payloads — a
+451
;; 0-length chunk would be read as the terminator
+452
;; mid-stream.
+453
(let ((bv (if (string? data)
+454
(string->utf8 data)
+455
data)))
+456
(if (= (bytevector-length bv) 0)
+457
#t
+458
(if (write-fn sock (frame-chunk bv)) #t #f)))
+459
(if (write-fn sock data) #t #f))))
460
;; close callback
461
(lambda ()
462
(set! closed #t)))
+463
;; Terminate the chunked stream.
+464
(when chunked
+465
(write-fn sock chunked-terminator))
466
#t))
467
(else #f)))))
468
@@ -429,9 +487,19 @@
487
;;; Parse an HTTP Range header value into (start . end).
488
;;; Returns #f if the header is absent or malformed.
489
;;;
+490
;;; Only single byte-ranges are supported. Three forms are recognized:
+491
;;; - `bytes=A-B` first-last, inclusive => (A . B)
+492
;;; - `bytes=A-` open-ended from A to EOF => (A . #f)
+493
;;; - `bytes=-N` suffix: the last N bytes => (#f . N)
+494
;;;
+495
;;; The suffix form uses a `#f` start to distinguish "last N bytes" from
+496
;;; an absolute range starting at N; `resolve-range` turns either into
+497
;;; concrete indices against the file size.
+498
;;;
499
;;; ```scheme
500
;;; (parse-range-header "bytes=0-499") ; => (0 . 499)
501
;;; (parse-range-header "bytes=500-") ; => (500 . #f)
+502
;;; (parse-range-header "bytes=-500") ; => (#f . 500)
503
;;; (parse-range-header #f) ; => #f
504
;;; ```
505
(define (parse-range-header header)
@@ -446,11 +514,50 @@
514
#f
515
(let* ((start-str (substring range-str 0 dash-pos))
516
(end-str (substring range-str (+ dash-pos 1) (string-length range-str)))
449
(start (string->number start-str))
+517
(start (if (string-empty? start-str) #f (string->number start-str)))
518
(end (if (string-empty? end-str) #f (string->number end-str))))
451
(if start
452
(cons start end)
453
#f))))))))
+519
(cond
+520
;; Absolute range: start present (end optional).
+521
(start (cons start end))
+522
;; Suffix range `bytes=-N`: start absent, end present.
+523
(end (cons #f end))
+524
;; `bytes=-` or otherwise empty — malformed.
+525
(else #f)))))))))
+526
+527
;;; Resolve a parsed Range (from `parse-range-header`) against a known
+528
;;; total file size into concrete inclusive byte indices.
+529
;;;
+530
;;; Returns `(start . end)` (both inclusive, satisfiable), or the symbol
+531
;;; `unsatisfiable` when the range cannot be served (caller should emit
+532
;;; 416). `total` is the file's byte length.
+533
;;;
+534
;;; ```scheme
+535
;;; (resolve-range (cons 0 99) 1000) ; => (0 . 99)
+536
;;; (resolve-range (cons 500 #f) 1000) ; => (500 . 999)
+537
;;; (resolve-range (cons #f 500) 1000) ; => (500 . 999) ; last 500 bytes
+538
;;; (resolve-range (cons 2000 #f) 1000); => unsatisfiable
+539
;;; ```
+540
(define (resolve-range parsed total)
+541
(let ((start (car parsed))
+542
(end (cdr parsed)))
+543
(cond
+544
;; Suffix `bytes=-N`: the last N bytes. A zero-length suffix, or a
+545
;; suffix of an empty file, is unsatisfiable.
+546
((not start)
+547
(if (or (not end) (<= end 0) (= total 0))
+548
'unsatisfiable
+549
(let ((n (if (> end total) total end)))
+550
(cons (- total n) (- total 1)))))
+551
;; A start at or past EOF cannot be satisfied.
+552
((>= start total) 'unsatisfiable)
+553
;; Absolute range; clamp the end to the last byte.
+554
(else
+555
(let ((real-end (if end
+556
(if (> end (- total 1)) (- total 1) end)
+557
(- total 1))))
+558
(if (< real-end start)
+559
'unsatisfiable
+560
(cons start real-end)))))))
561
562
;;; Serve a file with automatic MIME type and optional Range support.
563
;;; Uses streaming I/O to send files in chunks without loading
@@ -479,32 +586,82 @@
586
(close-input-port port)
587
(close)))
588
482
(define (http-response/file path (keys: (range-start #f) (range-end #f)))
+589
;; Build a 206 Partial Content response for inclusive byte indices
+590
;; [start, end] of a file whose total size is `total`.
+591
(define (file-partial-response path total mime start end)
+592
(let* ((length (+ (- end start) 1))
+593
(content-range (str "bytes " (number->string start)
+594
"-" (number->string end)
+595
"/" (number->string total))))
+596
(http-response
+597
status: HTTP-PARTIAL-CONTENT
+598
headers: (dict content-type: mime
+599
content-length: (number->string length)
+600
content-range: content-range
+601
accept-ranges: "bytes")
+602
body: (lambda (write-chunk close)
+603
(stream-file-range write-chunk close path start length)))))
+604
+605
;; Build a full 200 OK streaming response for the whole file.
+606
(define (file-full-response path total mime)
+607
(http-response
+608
status: HTTP-OK
+609
headers: (dict content-type: mime
+610
content-length: (number->string total)
+611
accept-ranges: "bytes")
+612
body: (lambda (write-chunk close)
+613
(stream-file-range write-chunk close path 0 total))))
+614
+615
;; Build a 416 Range Not Satisfiable response, advertising the total size.
+616
(define (file-unsatisfiable-response total)
+617
(http-response
+618
status: HTTP-RANGE-NOT-SATISFIABLE
+619
headers: (dict content-type: "text/plain; charset=utf-8"
+620
content-range: (str "bytes */" (number->string total))
+621
accept-ranges: "bytes")
+622
body: "Requested Range Not Satisfiable"))
+623
+624
;;; Serve a file with automatic MIME type and optional Range support.
+625
;;;
+626
;;; With no range arguments the whole file is streamed as `200 OK`.
+627
;;;
+628
;;; Pass `range:` the raw request `Range` header value (e.g.
+629
;;; `(http-request-header req "Range")`) to honor byte ranges: a
+630
;;; satisfiable range yields `206 Partial Content` with `Content-Range`;
+631
;;; an unsatisfiable one yields `416 Range Not Satisfiable`; an absent or
+632
;;; malformed header falls back to the full `200` response. This is the
+633
;;; recommended way to wire incoming requests.
+634
;;;
+635
;;; `range-start:`/`range-end:` remain for callers that have already
+636
;;; resolved absolute indices (they bypass parsing and 416 handling).
+637
;;;
+638
;;; ```scheme
+639
;;; (http-response/file "/path/to/video.mp4")
+640
;;; (http-response/file "/path/to/video.mp4" range: (http-request-header req "Range"))
+641
;;; (http-response/file "/path/to/video.mp4" range-start: 0 range-end: 499)
+642
;;; ```
+643
(define (http-response/file path (keys: (range-start #f) (range-end #f) (range #f)))
644
(let* ((total (file-size path))
645
(mime (mime-type-for-file path)))
485
(if range-start
486
(let* ((end (or range-end (- total 1)))
487
(length (+ (- end range-start) 1))
488
(content-range (str "bytes " (number->string range-start)
489
"-" (number->string end)
490
"/" (number->string total))))
491
(http-response
492
status: HTTP-PARTIAL-CONTENT
493
headers: (dict content-type: mime
494
content-length: (number->string length)
495
content-range: content-range
496
accept-ranges: "bytes")
497
body: (lambda (write-chunk close)
498
(stream-file-range write-chunk close
499
path range-start length))))
500
(http-response
501
status: HTTP-OK
502
headers: (dict content-type: mime
503
content-length: (number->string total)
504
accept-ranges: "bytes")
505
body: (lambda (write-chunk close)
506
(stream-file-range write-chunk close
507
path 0 total))))))
+646
(cond
+647
;; Raw Range header wiring — parse + resolve, may yield 206 or 416.
+648
(range
+649
(let ((parsed (parse-range-header range)))
+650
(if (not parsed)
+651
;; Absent/malformed Range → full 200 (per RFC 7233 §3.1).
+652
(file-full-response path total mime)
+653
(let ((resolved (resolve-range parsed total)))
+654
(if (eq? resolved 'unsatisfiable)
+655
(file-unsatisfiable-response total)
+656
(file-partial-response path total mime
+657
(car resolved) (cdr resolved)))))))
+658
;; Pre-resolved absolute indices.
+659
(range-start
+660
(file-partial-response path total mime
+661
range-start (or range-end (- total 1))))
+662
;; No range at all → full file.
+663
(else
+664
(file-full-response path total mime)))))
665
666
;; ============================================================
667
;; Server-Sent Events (SSE)
src/sigil/http/server.sglmodified
@@ -628,6 +628,49 @@
628
(string-length line)))))
629
(loop (cdr lines) headers))))))
630
+631
;; Should this request's connection stay open after the response?
+632
;; HTTP/1.1 defaults to persistent; HTTP/1.0 defaults to close. An explicit
+633
;; `Connection: close` always closes; `Connection: keep-alive` keeps an
+634
;; HTTP/1.0 connection open.
+635
(define (request-wants-keep-alive? request)
+636
(let* ((version (http-request-version request))
+637
(conn (http-request-header request "Connection"))
+638
(conn-lc (and conn (string-downcase conn))))
+639
(cond
+640
((and conn-lc (string-contains? conn-lc "close")) #f)
+641
((and conn-lc (string-contains? conn-lc "keep-alive")) #t)
+642
((and (string? version) (string=? version "HTTP/1.1")) #t)
+643
(else #f))))
+644
+645
;; Does the response carry an explicit Content-Length (i.e. known framing,
+646
;; so a streaming body can be delimited without chunked encoding)?
+647
(define (response-has-length? response)
+648
(and (dict-ref (http-response-headers response) content-length: #f) #t))
+649
+650
;; Will the response we send on the normal (non-streaming) path carry a
+651
;; determinable Content-Length? True for string/bytevector/#f bodies (whose
+652
;; length ensure-headers computes) and for any response with an explicit
+653
;; Content-Length header. A procedure body without an explicit length is
+654
;; NOT determinable — this only occurs for a HEAD to a chunked-style route,
+655
;; where keep-alive would leave the connection unframed, so we must close.
+656
(define (response-known-length? response)
+657
(or (response-has-length? response)
+658
(not (procedure? (http-response-body response)))))
+659
+660
;; Reset a client's per-request parse state so the same (kept-alive) socket
+661
;; can serve the next request. `created-at` is refreshed so the age-based
+662
;; timeout measures idle time since the last completed request rather than
+663
;; since the connection was accepted.
+664
(define (reset-client-for-keep-alive client)
+665
(http-client client
+666
buffer: (make-bytevector 0)
+667
headers-complete: #f
+668
content-length: #f
+669
body-bytes-read: 0
+670
request: #f
+671
response-started: #f
+672
created-at: (current-milliseconds)))
+673
674
;;; Handle a complete request
675
(define (handle-request server client request)
676
(let ((handler (http-server-handler server))
@@ -658,19 +701,34 @@
701
(let ((body (http-response-body response))
702
(head? (eq? (http-request-method request) 'HEAD)))
703
(if (and (procedure? body) (not head?))
661
;; Streaming response - spawn goroutine and keep socket open
662
(begin
+704
;; Streaming response — a goroutine owns the socket. Frame with
+705
;; chunked transfer-encoding when the length is unknown (SSE,
+706
;; bare producers); http-response/file keeps its Content-Length
+707
;; framing. The connection is closed when the stream ends —
+708
;; reusing a streamed socket for keep-alive is a documented
+709
;; follow-up (needs the select loop to re-adopt the socket).
+710
(let ((chunked? (not (response-has-length? response))))
711
(go (guard (exn
712
(else
713
(log-server-error "Streaming response goroutine crashed" exn)))
666
(send-response sock response)
+714
(send-response sock response chunked: chunked?)
715
(socket-close sock)))
716
#f) ; Remove from normal client list (goroutine owns socket now)
669
;; Normal response (or HEAD) - send and close
670
(begin
671
(send-response sock response head?: head?)
672
(socket-close sock)
673
#f)))))) ; Remove client from list
+717
;; Normal response (or HEAD). Keep the connection alive for
+718
;; HTTP/1.1 (unless the client asked to close) ONLY when the
+719
;; response has a determinable Content-Length — otherwise the
+720
;; peer can't frame a persistent connection, so we must close.
+721
;; Reset the client's parse state and keep it in the select
+722
;; loop to read the next request on the same socket.
+723
(let ((keep? (and (request-wants-keep-alive? request)
+724
(http-server-running server)
+725
(response-known-length? response))))
+726
(send-response sock response head?: head? keep-alive: keep?)
+727
(if keep?
+728
(reset-client-for-keep-alive client)
+729
(begin
+730
(socket-close sock)
+731
#f))))))))
732
733
;;; Send a response to socket.
734
;;;
@@ -679,7 +737,7 @@
737
;;; so we loop until the full data is sent. Converts strings to
738
;;; bytevectors for correct byte-offset tracking (socket-write
739
;;; returns bytes written, not characters).
682
(define (send-response sock response (keys: (head? #f)))
+740
(define (send-response sock response (keys: (head? #f) (keep-alive #f) (chunked #f)))
741
(write-http-response response sock
742
(lambda (s data)
743
(let* ((bv (if (string? data) (string->utf8 data) data))
@@ -700,7 +758,7 @@
758
(await-writable s)
759
(loop offset (+ attempts 1)))
760
#f)))))))
703
head?: head?))
+761
head?: head? keep-alive: keep-alive chunked: chunked))
762
763
;;; Send an error response
764
(define (send-error-response sock status message)
test/integration/keepalive-range-server-main.sgladded
@@ -0,0 +1,56 @@
+1
;;; (t4t2-server main) - integration test server for T4 (keep-alive + chunked)
+2
;;; and T2 (Range/206/416).
+3
;;;
+4
;;; Usage: t4t2-server <port> <asset-path>
+5
;;;
+6
;;; Routes:
+7
;;; /hello -> plain 200, known length (keep-alive eligible)
+8
;;; /chunked -> streaming procedure body WITHOUT Content-Length
+9
;;; (server frames it as Transfer-Encoding: chunked)
+10
;;; /file -> http-response/file wired to the request Range header
+11
;;; (200 full, 206 partial, or 416 unsatisfiable)
+12
;;;
+13
;;; Started as a BARE http-serve (no with-async) so the T1 self-scheduler
+14
;;; keeps working alongside keep-alive.
+15
+16
(define-library (t4t2-server main)
+17
(import (sigil core)
+18
(sigil io)
+19
(sigil process)
+20
(sigil http server)
+21
(sigil http request)
+22
(sigil http response))
+23
+24
(export main)
+25
+26
(begin
+27
+28
(define *asset-path* (make-parameter "/dev/null"))
+29
+30
(define (handler request)
+31
(let ((path (http-request-path request)))
+32
(cond
+33
((string=? path "/hello")
+34
(http-response/text HTTP-OK "Hello, World!"))
+35
;; Streaming body, unknown length -> chunked transfer-encoding.
+36
((string=? path "/chunked")
+37
(http-response
+38
status: HTTP-OK
+39
headers: #{ content-type: "text/plain" }
+40
body: (lambda (write-chunk close)
+41
(write-chunk "chunk-A")
+42
(write-chunk "chunk-B")
+43
(write-chunk "chunk-C")
+44
(close))))
+45
;; File with Range wiring (T2).
+46
((string=? path "/file")
+47
(http-response/file (*asset-path*)
+48
range: (http-request-header request "Range")))
+49
(else (http-response/not-found)))))
+50
+51
(define (main)
+52
(let* ((args (cdr (command-line)))
+53
(port (string->number (list-ref args 0)))
+54
(asset (list-ref args 1)))
+55
(*asset-path* asset)
+56
(http-serve handler port: port host: "127.0.0.1")))))
test/integration/run-keepalive-range-tests.shadded
@@ -0,0 +1,267 @@
+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.
+12
#
+13
# Requires: curl, python3, a working `sigil` toolchain, network on first run.
+14
# test/integration/run-keepalive-range-tests.sh
+15
+16
set -u
+17
+18
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+19
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+20
SERVER_SRC="$SCRIPT_DIR/keepalive-range-server-main.sgl"
+21
+22
APP_DIR="$(mktemp -d /tmp/t4t2-app.XXXXXX)"
+23
BIN="$APP_DIR/build/dev/bin/t4t2-server"
+24
ASSET="$(mktemp /tmp/t4t2-asset.XXXXXX.bin)"
+25
PORT=18251
+26
SRV_PID=""
+27
FAILED=0
+28
+29
cleanup() {
+30
[ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null
+31
rm -rf "$APP_DIR" "$ASSET"
+32
}
+33
trap cleanup EXIT
+34
+35
pass() { echo "PASS: $1"; }
+36
fail() { echo "FAIL: $1"; FAILED=1; }
+37
+38
scaffold_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")))
+56
EOF
+57
}
+58
+59
start_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
+74
}
+75
+76
echo "Scaffolding + building integration bundle (from-path sigil-http)..."
+77
scaffold_app
+78
( cd "$APP_DIR" && sigil deps install >/dev/null 2>&1 && sigil build >"$APP_DIR/build.log" 2>&1 )
+79
if [ ! -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
+83
fi
+84
+85
# 4 KB asset for range tests.
+86
head -c 4096 /dev/urandom > "$ASSET"
+87
ASSET_SIZE=$(stat -c%s "$ASSET")
+88
+89
if ! start_server; then
+90
echo "Some integration tests FAILED (startup)."; exit 1
+91
fi
+92
+93
# ---------------------------------------------------------------------------
+94
# T4.1 — keep-alive: two requests reuse ONE TCP connection (curl).
+95
# ---------------------------------------------------------------------------
+96
REUSE=$(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')
+98
if [ "$REUSE" -ge 1 ]; then
+99
pass "T4 keep-alive: curl re-used the connection for a 2nd request"
+100
else
+101
fail "T4 keep-alive: curl did NOT re-use the connection (count=$REUSE)"
+102
fi
+103
+104
# ---------------------------------------------------------------------------
+105
# T4.2 — response advertises Connection: keep-alive + Content-Length (HTTP/1.1).
+106
# ---------------------------------------------------------------------------
+107
H=$(curl -s --http1.1 -D - -o /dev/null "http://127.0.0.1:$PORT/hello")
+108
if 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"
+110
else
+111
fail "T4 keep-alive: missing keep-alive/Content-Length headers"; echo "$H" | sed 's/^/ /'
+112
fi
+113
+114
# ---------------------------------------------------------------------------
+115
# T4.3 — explicit Connection: close is honored.
+116
# ---------------------------------------------------------------------------
+117
H=$(curl -s --http1.1 -H 'Connection: close' -D - -o /dev/null "http://127.0.0.1:$PORT/hello")
+118
if echo "$H" | grep -qi '^connection: *close'; then
+119
pass "T4 keep-alive: Connection: close honored"
+120
else
+121
fail "T4 keep-alive: Connection: close NOT honored"; echo "$H" | sed 's/^/ /'
+122
fi
+123
+124
# ---------------------------------------------------------------------------
+125
# T4.4 — chunked framing over the wire (raw socket: exact bytes + terminator).
+126
# ---------------------------------------------------------------------------
+127
python3 - "$PORT" <<'PY'
+128
import socket, sys
+129
port = int(sys.argv[1])
+130
s = socket.create_connection(("127.0.0.1", port), timeout=5)
+131
s.sendall(b"GET /chunked HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
+132
data = b""
+133
s.settimeout(5)
+134
try:
+135
while True:
+136
b = s.recv(4096)
+137
if not b: break
+138
data += b
+139
except socket.timeout:
+140
pass
+141
s.close()
+142
head, _, body = data.partition(b"\r\n\r\n")
+143
ok = True
+144
if b"transfer-encoding: chunked" not in head.lower():
+145
print(" raw: missing Transfer-Encoding: chunked header"); ok = False
+146
if 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.
+149
if 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
+151
if 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
+153
if b"0\r\n\r\n" not in body:
+154
print(" raw: no 0\\r\\n\\r\\n terminator in body"); ok = False
+155
sys.exit(0 if ok else 1)
+156
PY
+157
if [ $? -eq 0 ]; then
+158
pass "T4 chunked: valid chunk framing + terminating 0\\r\\n\\r\\n (raw socket)"
+159
else
+160
fail "T4 chunked: framing/terminator check failed"
+161
fi
+162
+163
# ---------------------------------------------------------------------------
+164
# T4.5 — TWO serial requests served on ONE connection (raw socket, definitive).
+165
# ---------------------------------------------------------------------------
+166
python3 - "$PORT" <<'PY'
+167
import socket, sys
+168
port = int(sys.argv[1])
+169
+170
def 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
+186
+187
s = socket.create_connection(("127.0.0.1", port), timeout=5)
+188
s.settimeout(5)
+189
req = b"GET /hello HTTP/1.1\r\nHost: x\r\n\r\n"
+190
s.sendall(req)
+191
h1, b1 = read_by_content_length(s)
+192
s.sendall(req) # SAME socket, second request
+193
h2, b2 = read_by_content_length(s)
+194
s.close()
+195
ok = (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])
+197
if not ok:
+198
print(" raw serial: b1=%r b2=%r" % (b1, b2))
+199
sys.exit(0 if ok else 1)
+200
PY
+201
if [ $? -eq 0 ]; then
+202
pass "T4 keep-alive: two serial requests served on one socket (raw, no reconnect)"
+203
else
+204
fail "T4 keep-alive: serial-reuse on one socket failed"
+205
fi
+206
+207
# ---------------------------------------------------------------------------
+208
# T2.1 — Range bytes=0-99 -> 206 + Content-Range + 100 bytes.
+209
# ---------------------------------------------------------------------------
+210
DL="$(mktemp)"
+211
H=$(curl -s -r 0-99 -D - -o "$DL" "http://127.0.0.1:$PORT/file")
+212
CODE=$(echo "$H" | head -1 | grep -oE '[0-9]{3}')
+213
SZ=$(stat -c%s "$DL")
+214
if [ "$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"
+216
else
+217
fail "T2 range: bytes=0-99 (code=$CODE size=$SZ)"; echo "$H" | sed 's/^/ /'
+218
fi
+219
+220
# T2.2 — suffix bytes=-500 -> 206 + last 500 bytes.
+221
H=$(curl -s -r -500 -D - -o "$DL" "http://127.0.0.1:$PORT/file")
+222
CODE=$(echo "$H" | head -1 | grep -oE '[0-9]{3}')
+223
SZ=$(stat -c%s "$DL")
+224
EXP_START=$((ASSET_SIZE - 500))
+225
if [ "$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"
+227
else
+228
fail "T2 range: suffix bytes=-500 (code=$CODE size=$SZ)"; echo "$H" | sed 's/^/ /'
+229
fi
+230
+231
# T2.3 — open-ended bytes=500- -> 206 remainder.
+232
H=$(curl -s -r 500- -D - -o "$DL" "http://127.0.0.1:$PORT/file")
+233
CODE=$(echo "$H" | head -1 | grep -oE '[0-9]{3}')
+234
SZ=$(stat -c%s "$DL")
+235
if [ "$CODE" = "206" ] && [ "$SZ" = "$((ASSET_SIZE-500))" ]; then
+236
pass "T2 range: open-ended bytes=500- -> 206, $((ASSET_SIZE-500)) bytes"
+237
else
+238
fail "T2 range: open-ended bytes=500- (code=$CODE size=$SZ)"
+239
fi
+240
+241
# T2.4 — unsatisfiable -> 416 + Content-Range: bytes */total.
+242
H=$(curl -s -H "Range: bytes=$((ASSET_SIZE+1000))-" -D - -o /dev/null "http://127.0.0.1:$PORT/file")
+243
CODE=$(echo "$H" | head -1 | grep -oE '[0-9]{3}')
+244
if [ "$CODE" = "416" ] && echo "$H" | grep -qi "^content-range: *bytes \*/$ASSET_SIZE"; then
+245
pass "T2 range: unsatisfiable -> 416, Content-Range bytes */$ASSET_SIZE"
+246
else
+247
fail "T2 range: unsatisfiable (code=$CODE)"; echo "$H" | sed 's/^/ /'
+248
fi
+249
+250
# T2.5 — no Range -> 200, full body byte-exact.
+251
curl -s -D - -o "$DL" "http://127.0.0.1:$PORT/file" >"$APP_DIR/full-head.txt"
+252
if 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)"
+254
else
+255
fail "T2 range: full-file 200 mismatch"
+256
fi
+257
rm -f "$DL"
+258
+259
echo ""
+260
if [ "$FAILED" -eq 0 ]; then
+261
echo "All keep-alive + range integration tests passed."
+262
exit 0
+263
else
+264
echo "Some keep-alive + range integration tests FAILED."
+265
echo "--- server log ---"; cat "$APP_DIR/server.log"
+266
exit 1
+267
fi
test/test-response.sglmodified
@@ -5,6 +5,7 @@
5
(sigil async)
6
(sigil channels)
7
(sigil time)
+8
(sigil fs)
9
(sigil http response))
10
11
;; ============================================================
@@ -210,6 +211,190 @@
211
(assert-false (string-contains? output "chunk1"))
212
(assert-true (string-starts-with? output "HTTP/1.1 200 OK"))))))
213
+214
;; ============================================================
+215
;; T2: Range / 206 / 416 handling
+216
;; ============================================================
+217
+218
(test-group "parse-range-header"
+219
+220
(test "absolute range bytes=0-499"
+221
(assert-equal (parse-range-header "bytes=0-499") (cons 0 499)))
+222
+223
(test "open-ended range bytes=500-"
+224
(assert-equal (parse-range-header "bytes=500-") (cons 500 #f)))
+225
+226
(test "suffix range bytes=-500 => (#f . 500)"
+227
(assert-equal (parse-range-header "bytes=-500") (cons #f 500)))
+228
+229
(test "absent header => #f"
+230
(assert-false (parse-range-header #f)))
+231
+232
(test "malformed (no '=') => #f"
+233
(assert-false (parse-range-header "bytes 0-99")))
+234
+235
(test "malformed (bare dash) => #f"
+236
(assert-false (parse-range-header "bytes=-"))))
+237
+238
(test-group "resolve-range"
+239
+240
(test "absolute in-bounds"
+241
(assert-equal (resolve-range (cons 0 99) 1000) (cons 0 99)))
+242
+243
(test "open-ended clamps to last byte"
+244
(assert-equal (resolve-range (cons 500 #f) 1000) (cons 500 999)))
+245
+246
(test "end past EOF is clamped"
+247
(assert-equal (resolve-range (cons 900 5000) 1000) (cons 900 999)))
+248
+249
(test "suffix returns the last N bytes"
+250
(assert-equal (resolve-range (cons #f 500) 1000) (cons 500 999)))
+251
+252
(test "suffix larger than file yields whole file"
+253
(assert-equal (resolve-range (cons #f 5000) 1000) (cons 0 999)))
+254
+255
(test "start at/after EOF is unsatisfiable"
+256
(assert-equal (resolve-range (cons 1000 #f) 1000) 'unsatisfiable)
+257
(assert-equal (resolve-range (cons 2000 3000) 1000) 'unsatisfiable))
+258
+259
(test "empty file: any range unsatisfiable"
+260
(assert-equal (resolve-range (cons 0 0) 0) 'unsatisfiable)
+261
(assert-equal (resolve-range (cons #f 10) 0) 'unsatisfiable)))
+262
+263
;; http-response/file end-to-end range behavior against a real 1000-byte file.
+264
(define range-test-path "/tmp/sigil-http-range-test.bin")
+265
(define range-test-size 1000)
+266
+267
(define range-test-bytes
+268
(let ((bv (make-bytevector range-test-size 0)))
+269
(let loop ((i 0))
+270
(if (>= i range-test-size)
+271
bv
+272
(begin
+273
(bytevector-u8-set! bv i (modulo i 256))
+274
(loop (+ i 1)))))))
+275
+276
(write-file-bytes range-test-path range-test-bytes)
+277
+278
;; Drive a streaming file-response body to completion, returning its bytes.
+279
(define (collect-file-body res)
+280
(let ((chunks '()))
+281
((http-response-body res)
+282
(lambda (data)
+283
(set! chunks (cons (if (string? data) (string->utf8 data) data) chunks))
+284
#t)
+285
(lambda () #t))
+286
(if (null? chunks)
+287
(make-bytevector 0)
+288
(apply bytevector-append (reverse chunks)))))
+289
+290
(define (res-header res key)
+291
(dict-ref (http-response-headers res) key #f))
+292
+293
(test-group "http-response/file range wiring"
+294
+295
(test "bytes=0-99 -> 206 + Content-Range + 100 bytes"
+296
(let ((res (http-response/file range-test-path range: "bytes=0-99")))
+297
(assert-equal (http-response-status res) 206)
+298
(assert-equal (res-header res content-range:) "bytes 0-99/1000")
+299
(assert-equal (res-header res content-length:) "100")
+300
(assert-equal (res-header res accept-ranges:) "bytes")
+301
(let ((body (collect-file-body res)))
+302
(assert-equal (bytevector-length body) 100)
+303
(assert-equal (bytevector-copy body 0 100)
+304
(bytevector-copy range-test-bytes 0 100)))))
+305
+306
(test "open-ended bytes=500- -> 206, last 500 bytes"
+307
(let ((res (http-response/file range-test-path range: "bytes=500-")))
+308
(assert-equal (http-response-status res) 206)
+309
(assert-equal (res-header res content-range:) "bytes 500-999/1000")
+310
(assert-equal (res-header res content-length:) "500")
+311
(let ((body (collect-file-body res)))
+312
(assert-equal (bytevector-length body) 500)
+313
(assert-equal body (bytevector-copy range-test-bytes 500 1000)))))
+314
+315
(test "suffix bytes=-500 -> 206, final 500 bytes"
+316
(let ((res (http-response/file range-test-path range: "bytes=-500")))
+317
(assert-equal (http-response-status res) 206)
+318
(assert-equal (res-header res content-range:) "bytes 500-999/1000")
+319
(assert-equal (res-header res content-length:) "500")
+320
(let ((body (collect-file-body res)))
+321
(assert-equal (bytevector-length body) 500)
+322
(assert-equal body (bytevector-copy range-test-bytes 500 1000)))))
+323
+324
(test "unsatisfiable range -> 416 + Content-Range: bytes */total"
+325
(let ((res (http-response/file range-test-path range: "bytes=2000-3000")))
+326
(assert-equal (http-response-status res) 416)
+327
(assert-equal (res-header res content-range:) "bytes */1000")))
+328
+329
(test "absent Range -> 200 full body"
+330
(let ((res (http-response/file range-test-path)))
+331
(assert-equal (http-response-status res) 200)
+332
(assert-equal (res-header res content-length:) "1000")
+333
(assert-equal (res-header res accept-ranges:) "bytes")
+334
(assert-equal (bytevector-length (collect-file-body res)) 1000)))
+335
+336
(test "malformed Range -> 200 full body"
+337
(let ((res (http-response/file range-test-path range: "not-a-range")))
+338
(assert-equal (http-response-status res) 200)
+339
(assert-equal (res-header res content-length:) "1000"))))
+340
+341
;; ============================================================
+342
;; T4: Chunked transfer-encoding framing
+343
;; ============================================================
+344
+345
;; Collect the full wire output of write-http-response into one string.
+346
(define (capture-response res . kw)
+347
(let ((out '()))
+348
(define (w sock data)
+349
(set! out (cons (if (string? data) data (utf8->string data)) out))
+350
(if (string? data) (string-length data) (bytevector-length data)))
+351
(apply write-http-response res 'sock w kw)
+352
(apply string-append (reverse out))))
+353
+354
(test-group "chunked framing"
+355
+356
(test "streaming body without length -> Transfer-Encoding: chunked + terminator"
+357
(let* ((res (http-response
+358
status: 200
+359
headers: #{ content-type: "text/plain" }
+360
body: (lambda (emit finish)
+361
(emit "hello")
+362
(emit " world")
+363
(finish))))
+364
(out (capture-response res chunked: #t)))
+365
;; Header advertises chunked, and NO Content-Length is present.
+366
(assert-true (string-contains? out "transfer-encoding: chunked"))
+367
(assert-false (string-contains? out "content-length:"))
+368
;; Each write is framed <hexlen>\r\n<data>\r\n ...
+369
(assert-true (string-contains? out "5\r\nhello\r\n"))
+370
(assert-true (string-contains? out "6\r\n world\r\n"))
+371
;; ...and the stream ends with the zero-length terminator.
+372
(assert-true (string-ends-with? out "0\r\n\r\n"))))
+373
+374
(test "chunked skips empty writes (no premature terminator)"
+375
(let* ((res (http-response
+376
status: 200
+377
headers: #{ content-type: "text/plain" }
+378
body: (lambda (emit finish)
+379
(emit "") ; must be skipped, not framed as 0
+380
(emit "data")
+381
(finish))))
+382
(out (capture-response res chunked: #t)))
+383
(assert-true (string-contains? out "4\r\ndata\r\n"))
+384
;; Only ONE terminator, at the very end.
+385
(assert-true (string-ends-with? out "0\r\n\r\n"))))
+386
+387
(test "keep-alive: sets Connection: keep-alive"
+388
(let* ((res (http-response/text 200 "hi"))
+389
(out (capture-response res keep-alive: #t)))
+390
(assert-true (string-contains? out "connection: keep-alive"))
+391
(assert-true (string-contains? out "content-length: 2"))))
+392
+393
(test "default (no keep-alive) still Connection: close"
+394
(let* ((res (http-response/text 200 "hi"))
+395
(out (capture-response res)))
+396
(assert-true (string-contains? out "connection: close")))))
+397
398
;; ============================================================
399
;; SSE Heartbeat Tests
400
;; ============================================================