http: fix a default-path regression, a vacuous test, and the write loop
All found by an adversarial review of my own diff, not by the suite.
http-fetch-bytes routed PLAIN HTTP through the new bounded connect path even when the caller passed no timeout, because it defaults its own secs to 30. That changed the resolver used, the address ordering and the failure modes for every existing consumer, and it violated the one hard constraint on this work: with no timeout arguments, behaviour must be exactly as before. Plain HTTP now takes the bounded path only when the CALLER asked for a timeout. HTTPS keeps passing secs as it always did, which is also what now bounds its handshake, making its documented timeout true for the first time.
Its secs is also forced positive. The connection is set non-blocking unconditionally, so a non-positive timeout produced a #f deadline, which sent the write down the single blocking-write branch — a partial send on a non-blocking socket whose count is discarded, i.e. a silently truncated request. Non-blocking and "has a deadline" must not be able to disagree.
The write loop no longer treats "select says writable" as proof that a #f from socket-write was a real error rather than EAGAIN. Linux reports a socket writable once half the send buffer is free, so against a peer that IS draining that sequence is ordinary backpressure — it failed a perfectly good 4 MiB upload. It now waits and lets the deadline decide. The cost is that a genuine write error surfaces as a write timeout rather than immediately, which is the safe direction. The real fix belongs in socket-write, which should not make its callers guess.
resolve-addresses reverses what resolve-hostname returns. That function walks getaddrinfo forward and conses without reversing, so its list is RFC 6724 preference order backwards; connecting in that order tried the least preferred address first and gave it the first slice of the budget, inverting v4/v6 preference on a dual-stack host and disagreeing with the unbounded tcp-connect path.
tls-write* was fixed at two arguments while the bounded write loop passes start/end offsets, so EVERY HTTPS request with a timeout: raised an arity error. No stall test could see it: they all use plain HTTP or fail before the write. Only the live TLS control caught it.
TESTS. The two plain-HTTP connect tests assert only a FALSY result, and connect-first-address guards its native call, so an unbound procedure, a wrong arity or the whole path being deleted all produce #f too — they passed against no implementation at all. They are kept, because failing-correctly is half the behaviour, but the comment now says plainly that an assertion which only checks for a falsy result cannot distinguish "correctly false" from "nothing ran", and names the live control that supplies the other half.
Added to the live controls: a partial write that must resume at the right offset (4 MiB, above this host's measured EAGAIN threshold, echoed length checked — every other test of that loop asserts only that it timed out, so an off-by-one would have gone unnoticed); tls-connect/status reporting "connected", which nothing asserted anywhere; and a DRIPPING peer, which is the geometry that proved the sigil-tls handshake bound was wrong.
The runner's required-controls list now names every control, not four of them, so none can quietly stop running.
Documented, not fixed: http-download takes no timeout keywords and is blocking end to end. It is the API most likely to be pointed at a large slow remote and it is the one still able to freeze the process.
CHANGELOG.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++
RELEASE_NOTES_v0.18.3.md | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
package.sgl | 4 ++--
src/sigil/http/client.sgl | 128 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
test/integration/live-timeouts-main.sgl | 172 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/integration/run-timeout-tests.sh | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/integration/timeout-servers.py | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/test-client.sgl | 53 ++++++++++++++++++++++++++++++++---------------
8 files changed, 730 insertions(+), 47 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).## [0.18.3] - 2026-08-04### Changed- **`connect-timeout:` now bounds the TLS handshake as well as the TCP connect.** The connect timeout stopped at the moment the peer accepted, so a server that accepted and then never sent a ServerHello left the handshake read blocking with no way out. Under Sigil's cooperative scheduler that freezes the whole process rather than failing one request, which is how a production monitoring service ran for 55 days while its process, service status and listening port all reported it healthy. Widening the existing keyword rather than adding a new one means every caller already passing `connect-timeout:` is fixed by upgrading alone. Each phase gets the full value rather than a shared split, so a slow but working connect cannot consume the handshake's budget. Requires sigil-tls 0.16.5.- **`connect-timeout:` now applies to plain HTTP too.** It resolves the host and connects to each address under a deadline, giving each address a fair slice of what remains, so a blackholed first address cannot burn the whole budget and leave a working address untried.- **`timeout:` now bounds the request write.** A request usually fits the socket buffer, so the write usually returns at once, but a peer that never drains its receive queue fills the window and a large body blocks indefinitely. A write that runs out of time raises `HTTP request timed out: write deadline exceeded`, deliberately NOT marked ack-unconfirmed: the deadline can only fire with bytes still unsent, so the server holds a partial request it cannot act on and retrying is safe.### Fixed- **`http-fetch-bytes` was not bounded by its own `timeout` argument.** Its idle deadline loops detect "no data yet" from an empty read, which only a non-blocking connection returns, and it never set one. Its reads blocked and every deadline check between them was unreachable, so the argument looked like a safeguard and did nothing. The connection is now non-blocking, which is what those loops were written for, and they sleep between retries rather than spinning.### Known limitations- **`http-download` is not bounded at all.** It takes no timeout keywords and is blocking end to end: connect, write, header read and body stream. It is the API most likely to be pointed at a large, slow remote and it is the one still able to freeze the process. Bounding it means bounding a streaming read too, which is a larger change than this release.- **`timeout:` is a per-phase bound, not a total.** The write and the read each get the full value, so `(http-get url timeout: 5)` can spend up to 5 seconds writing and a further 5 reading. Sharing one deadline would have silently shortened the response window existing callers sized their value against; callers who read `timeout:` as a total should halve it.- **DNS resolution remains unbounded.** `getaddrinfo(3)` is a blocking call with no deadline and no portable cancellation, and both paths into it are inside native code. A resolver that stops answering still blocks a request for as long as the system resolver takes to give up, and neither `timeout:` nor `connect-timeout:` affects that. Stated here rather than left implicit: "sigil-http bounds its blocking segments" must not be read as "sigil-http cannot block".## [0.18.2] - 2026-07-23### AddedRELEASE_NOTES_v0.18.3.mdadded
Bound the TLS handshake and the request write, so an outbound call cannot block foreverAn outbound HTTP call passes through six blocking segments. Four of them wereunbounded, including the one that matters most: the TLS handshake. Once a peerhas accepted the TCP connection the connect phase is over, so `connect-timeout:`never reached the handshake read that follows. A server that accepts and thennever sends a ServerHello left that read blocking with no way out, and becauseSigil's scheduler is cooperative that does not fail one request, it freezes thewhole process.That is not a hypothetical. It ran a production monitoring service for 55 dayswhile its process, its service status and its listening port all reported ithealthy.**Name resolution is still unbounded, and this release does not change that.**See "Known limitations" below. Please do not read "sigil-http bounds itsblocking segments" as "sigil-http cannot block".## Who this fixes without a code change`connect-timeout:` now bounds the TLS handshake as well as the TCP connect, so**anything already passing `connect-timeout:` is fixed by upgrading alone**. Inthis estate that is courier, on both its Telegram poll and send paths, throughsigil-telegram's `connect-timeout` field.Everything else has to start passing it. A survey of the estate's repositoriesfound no other caller supplying `connect-timeout:` today, so vigil, kiln,bureau, tally and fjo each need to add it to get the protection.## Changes- `connect-timeout:` bounds the TLS handshake in addition to the TCP connect. Each phase gets the full value rather than a shared split, so a slow but working connect cannot consume the handshake's budget. The worst case before a request is sent is therefore about twice `connect-timeout:`.- `connect-timeout:` now works for plain HTTP as well. It resolves the host and connects to each address under a deadline, giving each address a fair slice of what remains, so a blackholed first address cannot burn the whole budget and leave a working address untried.- `timeout:` bounds the request write. A request usually fits the socket buffer, so the write usually returns at once, but a peer that never drains its receive queue fills the window and a large body then blocks indefinitely. A write that runs out of time raises `HTTP request timed out: write deadline exceeded`. That error is deliberately NOT marked ack-unconfirmed: a write deadline can only fire with bytes still unsent, so the server holds a partial request it cannot act on and retrying is safe.- The response read keeps its own fresh deadline rather than sharing the write's, so the response window that `timeout:` has always meant is unchanged. **The consequence is that `timeout:` is now a per-phase bound rather than a total**: `(http-get url timeout: 5)` can spend up to 5 seconds writing and a further 5 reading. Sharing one deadline would have silently shortened the response window that existing callers sized their value against, which seemed the worse of the two changes, but callers who read `timeout:` as a total should halve it.## Fixed- `http-fetch-bytes` was not bounded by its own `timeout` argument. Its idle deadline loops detect "no data yet" from an empty read, which only a non-blocking connection ever returns, and it never set one. So its reads blocked and every deadline check between them was unreachable: the argument looked like a safeguard and did nothing. The connection is now non-blocking, which is what those loops were written for, and they sleep between retries instead of spinning.## Known limitations- **`http-download` is not bounded at all.** It takes no timeout keywords and is blocking end to end: connect, write, header read and body stream. It is the API most likely to be pointed at a large, slow remote, and it is the one still able to freeze the process. Bounding it means bounding a streaming read as well, which is a larger change than this release. Stated here rather than left to be discovered.- **DNS resolution is unbounded.** `getaddrinfo(3)` is a blocking call with no deadline argument and no portable cancellation, and both paths into it are inside native code: the bounded plain-HTTP connect must resolve before it can connect per-address, and `tls-connect` resolves inside the same native call that performs its bounded connect. A resolver that stops answering still blocks a request for as long as the system resolver takes to give up, and neither `timeout:` nor `connect-timeout:` affects that. Callers needing a hard bound across resolution too have to get it outside this library, by running the request somewhere it can be abandoned, or by resolving ahead of time and requesting by address. Fixing it properly means adopting the async resolver that sigil-socket 0.17 added, which raises the required floor for every consumer and is a separate change.## Upgrade notesRequires sigil-tls 0.16.5, which carries the handshake bound itself. The pin isupdated accordingly.Every bound remains opt-in. With `timeout:` and `connect-timeout:` omitted thebehaviour is exactly what it was: blocking connect, blocking write, blockingread. Nothing changes for a caller that passes neither.package.sglmodified
(package name: "sigil-http" version: "0.18.2" version: "0.18.3" sigil: "^0.17.10" description: "HTTP client and server library for Sigil" url: "https://codeberg.org/sigil/sigil-http" dependencies: (list (from-git url: "codeberg:sigil/sigil-socket" version: "^0.16.0") (from-git url: "codeberg:sigil/sigil-tls" version: "^0.16.0") (from-git url: "codeberg:sigil/sigil-tls" version: "^0.16.5") (from-git url: "codeberg:sigil/sigil-json" version: "^0.16.0")) dev-dependencies: (listsrc/sigil/http/client.sglmodified
(define (tls-read-bytevector* conn . args) (apply (force %tls-read-bytevector) conn args)) (define (tls-write* conn data) ((force %tls-write) conn data)) ;; Variadic so the bounded write loop can pass start/end byte offsets. ;; It was fixed at two arguments, and every HTTPS request with a ;; `timeout:` raised an arity error through this wrapper — invisible to ;; every stall test, because those either use plain HTTP or fail before ;; the write. Only a live TLS request caught it. (define (tls-write* conn data . start-end) (apply (force %tls-write) conn data start-end)) (define (tls-close* conn) ((force %tls-close) conn)) ;; Every bound is OPT-IN. With both keywords omitted the behaviour is ;; exactly what it was: blocking connect, blocking write, blocking read. ;; ;; THE TABLE ABOVE DESCRIBES `http-request` AND ITS WRAPPERS, plus ;; `http-fetch-bytes`. It does NOT describe `http-download`, which takes ;; no timeout keywords at all and is blocking end to end: connect, write, ;; header read and body stream. That is the API most likely to be ;; pointed at a large, slow remote, and it is the one still able to ;; freeze the process. Bounding it means bounding a streaming read as ;; well, which is a larger change than this one; until then it is stated ;; here rather than left for someone to discover from the outside. ;; ;; UNBOUNDED SEGMENT: DNS ;; ;; `getaddrinfo(3)` is a blocking C call with no deadline argument and no (define (timeout-set? timeout) (and timeout (number? timeout) (> timeout 0))) ;;; Resolve `host` to a list of IP address strings, or #f. ;;; Resolve `host` to a list of IP address strings in the resolver's own ;;; preference order, or #f. ;;; ;;; The list is REVERSED on the way out. `resolve-hostname` walks ;;; getaddrinfo's results forward and conses, so what it returns is the ;;; RFC 6724 preference order backwards. Connecting in that order would ;;; try the least-preferred address first and give it the first slice of ;;; the budget, silently inverting v4/v6 preference on a dual-stack host ;;; whenever a connect timeout is set. The unbounded `tcp-connect` path ;;; gets the order right, so this would also have made the two paths ;;; disagree. ;;; ;;; RESIDUAL: this is the one segment that is NOT bounded. See the ;;; "Unbounded segment: DNS" note below. ;;; RESIDUAL: the resolution itself is NOT bounded. See the ;;; "Blocking segments" section above. (define (resolve-addresses host) (let ((r (guard (exn (else #f)) (resolve-hostname host)))) (cond ((not r) #f) ((string? r) (list r)) ; older sigil-socket returned one address ((null? r) #f) (else r)))) (else (reverse r))))) ;;; Connect to the first address that answers, with the whole attempt ;;; bounded by `total-ms`. (tls-write* conn bv start end) (socket-write conn bv start end))) ;;; Is the connection currently writable? Used only to tell a plain ;;; socket's "would block" apart from a genuine write error: both come ;;; back as #f from `socket-write`, and treating an error as would-block ;;; would spin until the deadline instead of failing immediately. ;;; Wait a short tick for a plain socket to accept more bytes. ;;; ;;; `socket-write` returns #f for EAGAIN and for a genuine error alike, ;;; and NOTHING here can tell them apart. An earlier version tried to: ;;; if `select` said the socket was writable, the #f must have been a ;;; real error. That is wrong, and wrong in the destructive direction. ;;; Linux reports a socket writable once half the send buffer is free, so ;;; against a peer that IS draining the sequence EAGAIN-then-writable is ;;; ordinary backpressure, and treating it as an error failed a perfectly ;;; good 4 MiB upload. ;;; ;;; So we wait and retry, and let the DEADLINE end it. The cost is that a ;;; genuine write error surfaces as a write timeout rather than an ;;; immediate failure. That is the safe direction: a slow failure for a ;;; dead socket beats a spurious failure for a live one. The real fix ;;; belongs in `socket-write`, which should distinguish would-block from ;;; error rather than making its callers guess. ;;; ;;; Two things about `socket-select` that its type signature does not ;;; say, and that a plausible-looking call gets wrong in the reassuring ;;; float raises rather than being rounded. ;;; * it returns `(readable writable)`, a two-element list of lists, ;;; NOT a flat list of ready sockets. So `(pair? (socket-select ...))` ;;; is ALWAYS true and would report every socket writable, turning ;;; every would-block into a spurious write error. (define (conn-writable? conn) (pair? (cadr (socket-select '() (list conn) *write-poll-interval-ms*)))) ;;; is ALWAYS true, which is how the mistake above got written. (define (wait-writable! conn) ;; Blocks up to the poll interval when the socket is NOT writable. When ;; it IS writable the call returns at once, so sleep instead — without ;; that, a socket that is writable but refusing bytes spins hot until ;; the deadline. (if (pair? (cadr (socket-select '() (list conn) *write-poll-interval-ms*))) (sleep *write-poll-interval*))) ;;; Put a connection (socket or TLS) into non-blocking mode. ;;; Used by the timeout path so reads return immediately when no ;; TLS reports #f only for real errors; a non-blocking ;; TLS write that cannot proceed returns 0. ((and (not n) (tls-connection?* conn)) #f) ;; A plain socket returns #f for EAGAIN as well as for a ;; genuine error, so ask select() which one it was. ((not n) (if (conn-writable? conn) #f (loop sent))) ;; A plain socket returns #f for EAGAIN and for a real ;; error alike. See `wait-writable!` for why this waits ;; rather than trying to tell them apart. ((not n) (wait-writable! conn) (loop sent)) ((= n 0) (sleep *write-poll-interval*) (loop sent)) ;;; Low-level function for making HTTP requests. Prefer the convenience ;;; functions (http-get, http-post, etc.) for common cases. ;;; ;;; The optional `timeout:` keyword bounds the response read. When ;;; given a positive number of seconds, the read is made ;;; non-blocking and a timeout error is raised if no complete ;;; response arrives before the deadline (so a half-open/blackholed ;;; connection fails cleanly instead of hanging). When omitted (the ;;; default), reads block exactly as before. ;;; The optional `timeout:` keyword (seconds) bounds the request WRITE ;;; and the response READ, each against its own deadline. The connection ;;; is made non-blocking and a timeout error is raised if the request ;;; cannot be sent, or no complete response arrives, in time. Omitted, ;;; both block exactly as before. ;;; ;;; A read timeout is marked ack-unconfirmed (`http-ack-unconfirmed?`): ;;; the request went out, so retrying may re-deliver it. A WRITE timeout ;;; is not, because it can only fire with bytes still unsent, leaving the ;;; server a partial request it cannot act on. ;;; ;;; The optional `connect-timeout:` keyword (seconds) bounds getting to ;;; the point where the request can be sent: the TCP connect, so a ;;; blackholed address can't hang on the OS SYN timeout, AND the TLS ;;; handshake, which the connect phase does not cover. Each gets the full ;;; value, so the ceiling before the request is sent is about twice it. ;;; Omitted/#f keeps the blocking connect and handshake. ;;; ;;; The optional `connect-timeout:` keyword (seconds) bounds the ;;; HTTPS connect phase, so a blackholed address can't hang on the ;;; OS SYN timeout. Omitted/#f keeps the blocking connect. ;;; NOT bounded by either keyword: DNS resolution. See the ;;; "Blocking segments" section at the top of this file. ;;; ;;; ```scheme ;;; (http-request 'GET "https://api.example.com/users" ;;; (and r (bytevector-length (dict-ref r body: #f)))) ;;; ``` (define (http-fetch-bytes method url (keys: (headers #{}) (body #f) (timeout #f))) (let ((secs (or timeout fetch-default-timeout))) ;; `secs` is always POSITIVE: the connection below is put into ;; non-blocking mode unconditionally, and a non-positive value would ;; produce a #f deadline, which sends the write down the single ;; blocking-write branch. On a non-blocking socket that is a partial ;; send whose count is discarded, i.e. a silently truncated request. ;; Non-blocking and "has a deadline" must not be able to disagree. (let ((secs (if (and timeout (number? timeout) (> timeout 0)) timeout fetch-default-timeout))) (guard (e (#t #f)) (let ((parsed (parse-url url))) (and parsed (let ((conn (connect-to-server parsed secs))) ;; Plain HTTP keeps its ORIGINAL unbounded connect unless the ;; caller actually asked for a timeout. `secs` defaults to 30 ;; on its own, and routing a caller who passed nothing down ;; the bounded connect path would change the resolver used, ;; the address ordering and the failure modes for every ;; existing consumer. HTTPS keeps passing `secs`, which is ;; what it always did; that is also what now bounds its ;; handshake, making this function's documented timeout true ;; for the first time. (let* ((https? (string=? (url-scheme parsed) "https")) (conn (connect-to-server parsed (and (or https? timeout) secs)))) (and conn (guard (e (#t (begin (fetch-safe-close conn) #f))) ;; The idle-deadline loops below detect "no datatest/integration/live-timeouts-main.sgladded
;;; Live positive controls for the blocking-segment bounds.;;;;;; Run by test/integration/run-timeout-tests.sh, which stands up the;;; servers and passes their ports in the environment.;;;;;; Named `-main.sgl` rather than `test-*.sgl` deliberately: `sigil test`;;; globs test/**/test-*.sgl, so a `test-` name here would be picked up by a;;; bare `sigil test` and fail with no servers running. The other files in;;; this directory follow the same convention for the same reason.;;;;;; WHY THIS FILE EXISTS. Every in-suite test of the bounds asserts a;;; FAILURE: a stalled peer must give up on time. Not one of them would;;; notice if the bounds broke every SUCCESSFUL request, because a client;;; that could no longer complete a TLS handshake at all would satisfy all;;; of them. The whole point here is the other direction: real servers,;;; bounds armed, requests must still work.;;;;;; The HTTPS case is the load-bearing one. The handshake bound is new code;;; in the middle of every outbound TLS connection in the estate.(import (sigil test) (sigil core) (sigil time) (sigil tls) (sigil http client) (sigil http response))(define (env-port name) (let ((v (getenv name))) (and v (string->number v))))(define http-port (env-port "SIGIL_HTTP_TEST_HTTP_PORT"))(define tls-port (env-port "SIGIL_HTTP_TEST_TLS_PORT"))(define drip-port (env-port "SIGIL_HTTP_TEST_DRIP_PORT"))(define (http-url port path) (string-append "http://127.0.0.1:" (number->string port) path))(define (https-url port path) (string-append "https://127.0.0.1:" (number->string port) path))(define (seconds-of thunk) (let* ((t0 (current-second)) (v (thunk))) (cons (- (current-second) t0) v)));; A missing port is a HARNESS FAILURE, not a reason to skip. A test that;; quietly passes because it never ran is the exact failure mode these;; bounds exist to eliminate.(test-group "live timeout positive controls" (test "harness: all three servers were configured" (assert-true (and http-port tls-port drip-port))) ;; --- Default behaviour is unchanged -------------------------------- (test "plain http with NO timeout arguments still works" (let ((res (http-get (http-url http-port "/")))) (assert-true (http-response? res)) (assert-equal 200 (http-response-status res)))) (test "https with NO timeout arguments still works" ;; The handshake bound must be genuinely opt-in. With no arguments the ;; original blocking handshake has to run and succeed. (let ((res (http-get (https-url tls-port "/")))) (assert-true (http-response? res)) (assert-equal 200 (http-response-status res)))) ;; --- The bounds do not break healthy requests ---------------------- (test "https with the handshake bound ARMED still completes a real handshake" (let* ((measured (seconds-of (lambda () (http-get (https-url tls-port "/") connect-timeout: 5 timeout: 5)))) (elapsed (car measured)) (res (cdr measured))) (assert-true (http-response? res)) (assert-equal 200 (http-response-status res)) ;; Promptly, not just eventually: a bound that made every handshake ;; wait out its deadline before succeeding would still return 200. (assert-true (< elapsed 2)))) (test "plain http with both bounds armed still works" (let ((res (http-get (http-url http-port "/") connect-timeout: 5 timeout: 5))) (assert-true (http-response? res)) (assert-equal 200 (http-response-status res)))) (test "a POST body still arrives intact through the bounded write loop" ;; The write path is no longer one blocking call; it is a partial-write ;; loop over byte offsets. An off-by-one there would corrupt or truncate ;; the body while every timeout test stayed green. The server echoes the ;; body length back. (let* ((body (make-string 100000 #\z)) (res (http-post (http-url http-port "/echo-length") body connect-timeout: 5 timeout: 10))) (assert-true (http-response? res)) (assert-equal 200 (http-response-status res)) (assert-equal "100000" (http-response-body res)))) (test "a POST body still arrives intact over TLS" (let* ((body (make-string 100000 #\z)) (res (http-post (https-url tls-port "/echo-length") body connect-timeout: 5 timeout: 10))) (assert-true (http-response? res)) (assert-equal 200 (http-response-status res)) (assert-equal "100000" (http-response-body res)))) (test "a partial write resumes at the right offset" ;; The write is no longer one blocking call; it is a loop over byte ;; offsets that resumes after a partial send. Every OTHER test of that ;; loop asserts only that it TIMED OUT, so an off-by-one in the resume ;; (passing the remaining length as `end`, say) would corrupt or truncate ;; bodies while the whole suite stayed green. ;; ;; 4 MiB is above the measured EAGAIN threshold on this host (~2.6 MiB), ;; so partial writes definitely occur, and the server is draining, so the ;; loop definitely completes. The echoed length is the correctness check. (let* ((body (make-string (* 4 1024 1024) #\w)) (res (http-post (http-url http-port "/echo-length") body connect-timeout: 10 timeout: 30))) (assert-true (http-response? res)) (assert-equal 200 (http-response-status res)) (assert-equal "4194304" (http-response-body res)))) ;; --- The handshake bound, against BOTH stall geometries ------------- (test "tls-connect/status reports connected for a live peer" ;; The only place in either repo that asserts the "connected" status. ;; Without it, `*status_out = "connected"` could be deleted from ;; tls_connect_core and every other test would still pass. (let ((r (tls-connect/status "127.0.0.1" tls-port 5000 5000))) (assert-equal "connected" (car r)) (assert-true (cdr r)) (tls-close (cdr r)))) (test "the handshake bound fires against a DRIPPING peer, not just a silent one" ;; THE CONTROL MY FIRST RIG WAS MISSING, and the reason the first version ;; of this bound was wrong. ;; ;; A peer that sends NOTHING cannot distinguish a per-read bound from a ;; per-handshake one: both fire. This peer sends a valid TLS record ;; header declaring a 16384-byte body and then drips one byte every ;; 600 ms. Against a per-read bound each byte restarts the clock: ;; measured at 13.06 s for a 1000 ms bound, and never firing at all while ;; the drip continued. Against a total bound it fires on time. (let* ((measured (seconds-of (lambda () (tls-connect/status "127.0.0.1" drip-port 1000 1000)))) (elapsed (car measured)) (r (cdr measured))) ;; It must be a TIMEOUT, not a protocol rejection. If the drip peer's ;; header were malformed the client would bail instantly with ;; "handshake-failed" and this test would prove nothing. (assert-equal "handshake-timeout" (car r)) (assert-false (cdr r)) ;; It must have WAITED, so we know bytes were arriving and being ;; consumed rather than the connection failing outright. (assert-true (>= elapsed 0.5)) ;; And it must not have waited much past its own deadline. This is the ;; assertion that fails on a per-read bound: 13.06 s against 1 s. (assert-true (< elapsed 4)))) (test "http-fetch-bytes still returns the body byte-for-byte" ;; http-fetch-bytes now sets the connection non-blocking so its own idle ;; deadlines can fire. If that broke its reads, this returns short. (let ((r (http-fetch-bytes 'GET (http-url http-port "/bytes") timeout: 10))) (assert-true (dict? r)) (assert-equal 200 (dict-ref r status: #f)) (assert-equal 65536 (bytevector-length (dict-ref r body: #f))))))(run-tests)test/integration/run-timeout-tests.shadded
#!/usr/bin/env bash# Live positive controls for the blocking-segment bounds.## Every in-suite test of those bounds asserts a FAILURE: a stalled peer must# give up on time. None of them would notice if the bounds broke every# SUCCESSFUL request, because a client that could no longer complete a TLS# handshake at all would satisfy all of them. This script supplies the other# direction: real servers, bounds armed, requests must still work, and# bodies must still arrive intact through the new partial-write loop.## Usage:# test/integration/run-timeout-tests.sh [--redirects FILE]## While sigil-tls 0.16.5 is unreleased, pass the local redirect:# test/integration/run-timeout-tests.sh --redirects tls-redirects.sgl## Requires python3 and openssl. A MISSING TOOL IS A HARD FAILURE HERE, never# a skip: a harness that quietly stops testing what it claims to test is the# exact failure class these bounds exist to eliminate.set -uSCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"REDIRECTS=""if [ "${1:-}" = "--redirects" ]; then REDIRECTS="$2"fifail() { echo "FAIL: $*" >&2; exit 2; }command -v python3 >/dev/null 2>&1 || fail "python3 not found"command -v sigil >/dev/null 2>&1 || fail "sigil not found"# openssl is frequently absent from a bare PATH on this host even though it# is present in the store. Look, then fail loudly rather than degrading.OPENSSL="$(command -v openssl 2>/dev/null || true)"if [ -z "$OPENSSL" ]; then for candidate in /gnu/store/*openssl*/bin/openssl; do if [ -x "$candidate" ]; then OPENSSL="$candidate"; break; fi donefi[ -n "$OPENSSL" ] || fail "openssl not found on PATH or in /gnu/store; cannot generate a test certificate"WORK="$(mktemp -d /tmp/sigil-http-timeout-tests.XXXXXX)"SERVER_PID=""cleanup() { [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null rm -rf "$WORK"}trap cleanup EXITecho "=== live timeout positive controls ===""$OPENSSL" req -x509 -newkey rsa:2048 -nodes -days 2 \ -keyout "$WORK/key.pem" -out "$WORK/cert.pem" -subj "/CN=localhost" \ >/dev/null 2>&1 || fail "certificate generation failed"# Two free ports, chosen by the kernel so concurrent runs cannot collide.read -r HTTP_PORT TLS_PORT DRIP_PORT <<EOF$(python3 - <<'PY'import socketsocks = [socket.socket() for _ in range(3)]for s in socks: s.bind(("127.0.0.1", 0))ports = [s.getsockname()[1] for s in socks]for s in socks: s.close()print(ports[0], ports[1], ports[2])PY)EOF[ -n "${HTTP_PORT:-}" ] && [ -n "${TLS_PORT:-}" ] && [ -n "${DRIP_PORT:-}" ] \ || fail "could not allocate ports"python3 "$SCRIPT_DIR/timeout-servers.py" "$HTTP_PORT" "$TLS_PORT" \ "$WORK/cert.pem" "$WORK/key.pem" "$DRIP_PORT" >"$WORK/servers.log" 2>&1 &SERVER_PID=$!# Wait for the servers' own readiness line rather than sleeping a guess.for _ in $(seq 1 100); do grep -q "SERVERS READY" "$WORK/servers.log" 2>/dev/null && break sleep 0.1donegrep -q "SERVERS READY" "$WORK/servers.log" 2>/dev/null \ || { cat "$WORK/servers.log" >&2; fail "servers did not come up"; }echo "servers up: http=$HTTP_PORT tls=$TLS_PORT drip=$DRIP_PORT"# The certificate is self-signed, so verification is off for this run only.export SIGIL_TLS_INSECURE=1export SIGIL_HTTP_TEST_HTTP_PORT="$HTTP_PORT"export SIGIL_HTTP_TEST_TLS_PORT="$TLS_PORT"export SIGIL_HTTP_TEST_DRIP_PORT="$DRIP_PORT"cd "$REPO_ROOT" || fail "cannot enter $REPO_ROOT"if [ -n "$REDIRECTS" ]; then sigil test --redirects "$REDIRECTS" test/integration/live-timeouts-main.sgl \ 2>&1 | tee "$WORK/test.log"else sigil test test/integration/live-timeouts-main.sgl 2>&1 | tee "$WORK/test.log"fi# COVERAGE ASSERTION. `sigil test` exits 0 for a run that executed nothing,# so the exit status alone cannot distinguish "all passed" from "no tests# found". Name the controls that must have run.for required in \ "plain http with NO timeout arguments still works" \ "https with NO timeout arguments still works" \ "https with the handshake bound ARMED still completes a real handshake" \ "plain http with both bounds armed still works" \ "a POST body still arrives intact through the bounded write loop" \ "a POST body still arrives intact over TLS" \ "a partial write resumes at the right offset" \ "the handshake bound fires against a DRIPPING peer, not just a silent one" \ "tls-connect/status reports connected for a live peer" \ "http-fetch-bytes still returns the body byte-for-byte"do grep -qF "$required" "$WORK/test.log" \ || fail "expected control did not run: $required"donegrep -q "All tests passed" "$WORK/test.log" || fail "live positive controls failed"echo "=== live timeout positive controls: PASS ==="test/integration/timeout-servers.pyadded
#!/usr/bin/env python3"""Servers for the live timeout controls.Two healthy servers, deliberately boring: they answer correctly and promptly.Their whole job is to prove that the new bounds do NOT break a request thatshould work, which no stall rig can show.Routes: / 200, {"status":"ok"} /echo-length 200, the request body's byte length as text /bytes 200, exactly 65536 bytes of application/octet-streamAnd one DRIP peer, which is the control that a silent stall rig cannotprovide. See its docstring: it is the difference between a bound that isper-read and one that is per-handshake, and a rig made only of peers thatsend NOTHING cannot tell those apart.Usage: timeout-servers.py <http-port> <tls-port> <certfile> <keyfile> <drip-port>"""import http.serverimport socketimport sslimport sysimport threadingimport timeBYTES_LEN = 65536# One byte per interval. Long enough that a per-read bound of a comparable# size resets on every byte, which is exactly the defect being tested for.DRIP_INTERVAL_S = 0.6# A well-formed TLS record header: handshake (0x16), TLS 1.2 (0x0303), then a# declared body length of 0x4000 = 16384 bytes. The client will therefore sit# in mbedtls_ssl_fetch_input waiting for 16384 bytes that arrive one at a# time. The header must be VALID or the client rejects it immediately and the# result is "handshake-failed" rather than a timeout, which would test# nothing.TLS_RECORD_HEADER = bytes([0x16, 0x03, 0x03, 0x40, 0x00])def drip_server(port): """Accept, send a valid record header, then trickle body bytes forever. A peer that goes silent and a peer that DRIPS are different failure geometries, and a handshake bound can be correct against the first while being useless against the second: mbedtls_ssl_handshake does not return between steps and passes the full configured read timeout to every partial read, so each dribbled byte restarts the clock. Measured against an earlier build, a 1000 ms bound took 13.06 s to fire here, and never fired at all while the drip continued. """ srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("127.0.0.1", port)) srv.listen(16) def handle(conn): try: conn.sendall(TLS_RECORD_HEADER) while True: time.sleep(DRIP_INTERVAL_S) conn.sendall(b"\x00") except OSError: pass finally: try: conn.close() except OSError: pass while True: client, _ = srv.accept() threading.Thread(target=handle, args=(client,), daemon=True).start()class Handler(http.server.BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def _send(self, body, ctype="text/plain"): self.send_response(200) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): if self.path == "/bytes": self._send(bytes(i % 256 for i in range(BYTES_LEN)), "application/octet-stream") else: self._send(b'{"status":"ok"}', "application/json") def do_POST(self): length = int(self.headers.get("Content-Length", "0")) read = 0 while read < length: chunk = self.rfile.read(min(65536, length - read)) if not chunk: break read += len(chunk) if self.path == "/echo-length": self._send(str(read).encode()) else: self._send(b'{"status":"ok"}', "application/json") def log_message(self, *args): passdef serve(port, certfile=None, keyfile=None): srv = http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler) if certfile: ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.load_cert_chain(certfile, keyfile) srv.socket = ctx.wrap_socket(srv.socket, server_side=True) srv.serve_forever()def main(): http_port = int(sys.argv[1]) tls_port = int(sys.argv[2]) certfile = sys.argv[3] keyfile = sys.argv[4] drip_port = int(sys.argv[5]) threading.Thread(target=serve, args=(http_port,), daemon=True).start() threading.Thread(target=serve, args=(tls_port, certfile, keyfile), daemon=True).start() threading.Thread(target=drip_server, args=(drip_port,), daemon=True).start() # The runner waits for this line before starting the tests. print("SERVERS READY http=%d tls=%d drip=%d" % (http_port, tls_port, drip_port), flush=True) threading.Event().wait()if __name__ == "__main__": main()test/test-client.sglmodified
(test "a large body into a never-drained peer is bounded by timeout" (with-stalled-listener (lambda (port) ;; 6 MiB is chosen between two hard constraints, both measured on ;; this host. It must EXCEED the socket buffers or the kernel simply ;; absorbs the body and the write never blocks: 512 KiB, 2 MiB and ;; 4 MiB were all absorbed here (tcp_wmem max is 4 MiB). And it must ;; stay UNDER a cliff in the runtime's `string-append`, which takes ;; 2.3 ms for a 4 MiB string and 14.5 SECONDS for an 8 MiB one; ;; `build-request-string` appends the body, so an 8 MiB body spends ;; 45 s in string handling and swamps any timing assertion. ;; 6 MiB is chosen between two constraints, both measured on this ;; host. ;; ;; If this test ever fails as an ack-unconfirmed read timeout rather ;; than a write timeout, the host's socket buffers are larger than ;; the body: raise the size. It fails loudly rather than skipping. ;; It must EXCEED the socket buffers, or the kernel simply absorbs ;; the body and the write never blocks: 512 KiB, 2 MiB and 4 MiB were ;; all absorbed here (tcp_wmem max is 4 MiB). ;; ;; And it must stay small enough that handling it does not swamp the ;; timing assertion. At 8 MiB this call measured 46 s against a 1 s ;; deadline, all of it spent before the write loop's first deadline ;; check. Large-string allocation in the runtime is seconds-slow ;; (make-string of 8 MiB takes ~6 s standalone), and the collections ;; that provokes land wherever they land. At 6 MiB the same call ;; measures 1.02 s, so the assertion is measuring the deadline. ;; ;; If this ever fails as an ack-unconfirmed READ timeout rather than ;; a write timeout, the host's socket buffers are bigger than the ;; body: raise the size. It fails loudly rather than skipping. (let* ((url (string-append "http://127.0.0.1:" (number->string port) "/")) (body (make-string (* 6 1024 1024) #\x)) (measured (seconds-taken (lambda () (http-post url body timeout: 1)))) ;; --- Segment: plain-HTTP connect -------------------------------------- (test "plain-http connect-timeout resolves and connects through the bounded path" ;; The bounded path is a different code path from the default: it ;; resolves the host itself and connects per-address with a deadline. ;; A refused port must therefore still come back as a clean #f through ;; THAT path, not an error or a hang. ;; --- Segment: plain-HTTP connect -------------------------------------- ;; ;; READ THIS BEFORE ADDING TO THIS SECTION. The two tests below assert a ;; FALSY result, and a falsy result is the cheapest thing in the world to ;; produce: `connect-first-address` wraps its native call in a guard, so an ;; unbound procedure, a wrong arity, a wrong argument type or the whole ;; bounded path being deleted ALL yield #f as well. AN ASSERTION THAT ONLY ;; CHECKS FOR A FALSY RESULT CANNOT DISTINGUISH "CORRECTLY FALSE" FROM ;; "NOTHING RAN". On their own these two would pass against no ;; implementation at all. ;; ;; What makes them mean something is the LIVE control that has to hold at ;; the same time: "plain http with both bounds armed still works" in ;; test/integration/live-timeouts-main.sgl fetches a 200 through this exact ;; path from a real listener. Failure here plus success there is the pair ;; that pins the behaviour. Neither half is worth much alone, and the live ;; half is named in run-timeout-tests.sh's required-controls list so it ;; cannot quietly stop running. (test "plain-http connect-timeout returns a clean #f for a refused port" (let* ((tmp (tcp-listen 0 host: "127.0.0.1")) (port (cadr (socket-local-address tmp)))) (socket-close tmp)