Terminate response read on HTTP framing, not connection close
read-all-data previously read until the peer closed the connection (EOF) and parse-http-response ignored Content-Length, so a keep-alive or half-open peer that sent a COMPLETE response and then held the connection open hung the read forever (the original /mcp wedge) or, once a read timeout existed, tripped a spurious deadline on a request the server had already processed -> the caller retried -> duplicate delivery.
Make the read framing-aware: once headers arrive, complete as soon as the body is fully received per Content-Length, the chunked 0-terminator, or a body-less status/method (HEAD, 1xx, 204, 304). Only an unframed response (no Content-Length, not chunked) falls back to read-until-EOF. This terminates correctly with or without a timeout, so the ack is read reliably and the duplicate-retry trigger is removed at the source.
Verified with a hold-open server (full Content-Length response, never closes): the client now returns the parsed 200 in ~4-25ms instead of hanging (blocking) or timing out at the deadline. Adds framing unit tests; all existing behavior preserved (118/118).
src/sigil/http/client.sgl | 158 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
test/test-client.sgl | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 219 insertions(+), 20 deletions(-)src/sigil/http/client.sglmodified
;; Internal — exported for testing parse-http-response decode-chunked-body) decode-chunked-body find-header-end-bytes no-body-expected? detect-framing chunked-body-complete? framing-complete?) (begin (dict-set headers name value))) (loop (cdr lines) headers)))))) ;;; Find the end of the header block (\r\n\r\n) in a bytevector. ;;; Returns the index of the first \r, or #f if not yet present. (define (find-header-end-bytes bv) (let ((len (bytevector-length bv))) (let loop ((i 0)) (if (> (+ i 3) (- len 1)) #f (if (and (= (bytevector-u8-ref bv i) 13) (= (bytevector-u8-ref bv (+ i 1)) 10) (= (bytevector-u8-ref bv (+ i 2)) 13) (= (bytevector-u8-ref bv (+ i 3)) 10)) i (loop (+ i 1))))))) ;;; Does this method/status combination forbid a response body? ;;; HEAD never has a body; 1xx/204/304 never have a body (RFC 9112). (define (no-body-expected? method status) (or (eq? method 'HEAD) (and status (or (= status 204) (= status 304) (and (>= status 100) (< status 200)))))) ;;; Inspect the accumulated response bytes and determine how the body ;;; is framed. Returns #f while the header block is still incomplete, ;;; otherwise a descriptor: ;;; (no-body) — no body permitted; complete at headers ;;; (length <body-start> <n>) — fixed Content-Length body ;;; (chunked <body-start>) — chunked transfer-encoding ;;; (until-close) — unframed; read until the peer closes ;;; This lets the reader stop as soon as the full body has arrived ;;; instead of waiting for the connection to close (EOF), which a ;;; keep-alive/half-open peer may never do. (define (detect-framing method bv) (let ((he (find-header-end-bytes bv))) (if (not he) #f (let* ((body-start (+ he 4)) (header-str (utf8->string (bytevector-copy bv 0 he))) (lines (string-split header-str "\r\n")) (status-info (and (pair? lines) (parse-status-line (car lines)))) (status (and status-info (cadr status-info))) (headers (if (pair? lines) (parse-response-headers (cdr lines)) #{})) (te (dict-ref headers transfer-encoding: #f)) (cl (dict-ref headers content-length: #f))) (cond ((no-body-expected? method status) (list 'no-body)) ((and te (string-contains? (string-downcase te) "chunked")) (list 'chunked body-start)) (cl (let ((n (string->number (string-trim cl)))) (if n (list 'length body-start n) (list 'until-close)))) (else (list 'until-close))))))) ;;; Is the chunked body starting at `start` fully present in `bv`? ;;; Walks chunk-size lines until the terminating 0-size chunk and its ;;; closing CRLF (no trailers). Returns #f if more data is needed. (define (chunked-body-complete? bv start) (let ((len (bytevector-length bv))) (let loop ((pos start)) (let ((line-end (find-crlf-bytes bv pos))) (if (not line-end) #f (let* ((size-str (utf8->string (bytevector-copy bv pos line-end))) (sz (hex-string->number size-str))) (cond ((not sz) #f) ((= sz 0) ;; Final chunk: need the closing CRLF after "0\r\n". (let ((after (+ line-end 2))) (and (<= (+ after 2) len) (= (bytevector-u8-ref bv after) 13) (= (bytevector-u8-ref bv (+ after 1)) 10)))) (else ;; size-line CRLF + data + trailing CRLF (let ((next (+ line-end 2 sz 2))) (if (> next len) #f (loop next))))))))))) ;;; Is the response complete per its framing descriptor? ;;; `combined` is the accumulated bytevector (only needed for chunked). (define (framing-complete? framing total combined) (let ((mode (car framing))) (cond ((eq? mode 'no-body) #t) ((eq? mode 'length) (>= (- total (cadr framing)) (caddr framing))) ((eq? mode 'chunked) (chunked-body-complete? combined (cadr framing))) (else #f)))) ; until-close — rely on EOF ;;; Read HTTP response from connection ;;; Returns http-response or #f on error ;;; Returns http-response or #f on error. ;;; `method` is the request method (HEAD responses carry no body). ;;; `deadline` is a jiffy deadline (or #f). When set, the read loop ;;; enforces a timeout instead of treating an empty read as EOF. (define (read-http-response conn deadline) ;;; enforces a timeout instead of blocking on a stalled read. (define (read-http-response method conn deadline) ;; Read all available data (let ((data (read-all-data conn deadline))) (let ((data (read-all-data method conn deadline))) (if (or (not data) (string=? data "")) #f (parse-http-response data)))) ;;; Read all data from connection until closed. ;;; Reads as bytevectors and converts to string once at the end ;;; to avoid splitting multi-byte UTF-8 characters across chunks. ;;; Read a full HTTP response from the connection. ;;; Reads as bytevectors and converts to string once at the end to ;;; avoid splitting multi-byte UTF-8 characters across chunks. ;;; ;;; Termination is HTTP-framing-aware: once the header block has ;;; arrived, the response is considered complete as soon as the body ;;; is fully received per its framing (Content-Length, the chunked ;;; 0-terminator, or a body-less status/method). The reader does NOT ;;; wait for the connection to close — a keep-alive or half-open peer ;;; may hold it open indefinitely even after sending a complete ;;; response, which previously hung the read (or, with a deadline, ;;; tripped a spurious timeout on an already-delivered request). ;;; Only an unframed response (no Content-Length, not chunked) falls ;;; back to reading until EOF. ;;; ;;; When `deadline` is #f (the default, no timeout) the connection ;;; is blocking and an empty read means the stream is done — the ;;; original behavior, unchanged. When `deadline` is set the ;;; connection is non-blocking: an empty read means "no data yet", ;;; so we sleep briefly and retry until data arrives, the peer ;;; closes, or the deadline passes (which raises a timeout error). (define (read-all-data conn deadline) (let loop ((chunks '())) ;;; When `deadline` is #f, reads block. When set, the connection is ;;; non-blocking: an empty read means "no data yet", so we sleep ;;; briefly and retry until the response completes, the peer closes, ;;; or the deadline passes (which raises a timeout error). (define (read-all-data method conn deadline) (let loop ((chunks '()) (total 0) (framing #f)) (let ((chunk (conn-read-bytes conn 8192))) (cond ((not chunk) #f (utf8->string (apply bytevector-append (reverse chunks))))) ((eof-object? chunk) ;; Done ;; Peer closed — done (the only terminator for until-close). (utf8->string (apply bytevector-append (reverse chunks)))) ((= (bytevector-length chunk) 0) (if deadline ;; Non-blocking: no data yet — enforce the deadline. (if (deadline-expired? deadline) (begin (conn-close conn) (raise-http-timeout)) (begin (sleep *read-poll-interval*) (loop chunks))) (begin (sleep *read-poll-interval*) (loop chunks total framing))) ;; Blocking (no timeout): no data available, done. (utf8->string (apply bytevector-append (reverse chunks))))) (else (loop (cons chunk chunks))))))) (let* ((chunks* (cons chunk chunks)) (total* (+ total (bytevector-length chunk))) ;; Combine only while detecting headers or scanning a ;; chunked body; Content-Length completion is a cheap ;; byte-count check needing no recombination. (need-bytes (or (not framing) (eq? (car framing) 'chunked))) (combined (and need-bytes (apply bytevector-append (reverse chunks*)))) (framing* (or framing (and combined (detect-framing method combined))))) (if (and framing* (framing-complete? framing* total* combined)) (utf8->string (or combined (apply bytevector-append (reverse chunks*)))) (loop chunks* total* framing*)))))))) ;;; Parse HTTP response from string (define (parse-http-response data) ;; non-blocking so the read loop can enforce the deadline. (conn-write conn request-str) (when deadline (conn-set-non-blocking! conn)) (let ((response (read-http-response conn deadline))) (let ((response (read-http-response method conn deadline))) (conn-close conn) response)))))test/test-client.sglmodified
;;; Tests for (sigil http client) — chunked decoding and response parsing(import (sigil test) (sigil core) (sigil io) (sigil string) (sigil time) (sigil socket) (assert-true (procedure? (dict-ref api delete:))))));; ============================================================;; HTTP Response Framing (Content-Length / chunked / no-body);; ============================================================;;;; The read loop must terminate as soon as the framed body is fully;; received, NOT wait for the peer to close (EOF). A keep-alive or;; half-open peer can send a complete response and then hold the;; connection open indefinitely — which previously hung the read (or,;; with a timeout, tripped a spurious deadline on an already-delivered;; request, causing duplicate retries).(test-group "find-header-end-bytes" (test "locates the \\r\\n\\r\\n boundary" (let* ((bv (string->utf8 "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")) (pos (find-header-end-bytes bv))) (assert-true (and pos (> pos 0))) ;; bytes at pos must be \r \n \r \n (assert-equal (bytevector-u8-ref bv pos) 13) (assert-equal (bytevector-u8-ref bv (+ pos 1)) 10) (assert-equal (bytevector-u8-ref bv (+ pos 2)) 13) (assert-equal (bytevector-u8-ref bv (+ pos 3)) 10))) (test "returns #f when headers are incomplete" (assert-false (find-header-end-bytes (string->utf8 "HTTP/1.1 200 OK\r\nContent-Len")))))(test-group "no-body-expected?" (test "HEAD never has a body" (assert-true (no-body-expected? 'HEAD 200))) (test "204 No Content" (assert-true (no-body-expected? 'GET 204))) (test "304 Not Modified" (assert-true (no-body-expected? 'GET 304))) (test "1xx informational" (assert-true (no-body-expected? 'GET 100))) (test "200 GET does have a body" (assert-false (no-body-expected? 'GET 200))))(test-group "detect-framing" (test "Content-Length response" (let ((f (detect-framing 'GET (string->utf8 "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhel")))) (assert-equal (car f) 'length) (assert-equal (caddr f) 5))) (test "chunked response" (let ((f (detect-framing 'GET (string->utf8 "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n")))) (assert-equal (car f) 'chunked))) (test "HEAD with Content-Length is still body-less" (let ((f (detect-framing 'HEAD (string->utf8 "HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n")))) (assert-equal (car f) 'no-body))) (test "no Content-Length and not chunked falls back to until-close" (let ((f (detect-framing 'GET (string->utf8 "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nhi")))) (assert-equal (car f) 'until-close))) (test "incomplete headers yield #f" (assert-false (detect-framing 'GET (string->utf8 "HTTP/1.1 200 OK\r\nContent-Len")))))(test-group "framing-complete?" (test "length: complete when body bytes reach Content-Length" (assert-true (framing-complete? (list 'length 10 5) 15 #f))) (test "length: incomplete when short" (assert-false (framing-complete? (list 'length 10 5) 12 #f))) (test "no-body: always complete" (assert-true (framing-complete? (list 'no-body) 0 #f))) (test "until-close: never complete (relies on EOF)" (assert-false (framing-complete? (list 'until-close) 9999 #f))) (test "chunked: complete with full 0-terminated stream" (let ((bv (string->utf8 "5\r\nhello\r\n0\r\n\r\n"))) (assert-true (framing-complete? (list 'chunked 0) (bytevector-length bv) bv)))) (test "chunked: incomplete without terminator" (let ((bv (string->utf8 "5\r\nhello\r\n"))) (assert-false (framing-complete? (list 'chunked 0) (bytevector-length bv) bv)))));; ============================================================;; Read Timeout (opt-in);; ============================================================