server: http-server-stop signals the serve loop through a shared stop box
The server record is immutable: http-server-start and the serve loop derive updated copies, so a caller holding the record it passed to http-server-start never shares state with the loop's current record — a running-flag flip on one copy was invisible to the others, and stopping from another task silently did nothing. The loop kept serving forever (and, now that its waits suspend on the scheduler, would have kept a periodic wait alive indefinitely).
The stop signal now lives in a one-slot vector created per make-http-server and carried BY REFERENCE through every derived record. http-server-stop sets it (and still closes any sockets reachable from the record it was called on — the embedded/tick mode); the serve loop, whose waits are now all deadline-bounded (the idle listen wait was previously unbounded), sees the signal within the sweep interval, closes the sockets it owns, and http-server-start returns.
Probe phase 3 (run-starvation-test.sh) covers the goroutine-mode stop: idle server, http-server-stop from a sibling task holding the pre-start record — stop completes in ~100ms (hung forever before, watchdog-proven red on the prior server-loop). Suites: 144/144 + streaming integration all-pass; starvation phases unaffected (control 0ms / starved 1ms).
CHANGELOG.md | 14 ++++++++++++++
src/sigil/http/server.sgl | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------
test/integration/starvation-probe-main.sgl | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------
3 files changed, 186 insertions(+), 79 deletions(-)CHANGELOG.mdmodified
### Fixed- **`http-server-stop` now actually stops a running serve loop.** The server record is immutable — `http-server-start` and the serve loop derive updated copies — so a caller that held the record it passed to `http-server-start` never shared state with the loop, and stopping from another task silently did nothing (the loop kept running forever; with the loop now suspending on the scheduler, it would have kept a periodic wait alive indefinitely). The stop signal now lives in a one-slot vector created per `make-http-server` and carried by reference through every derived record; `http-server-stop` sets it and the loop — whose waits are all deadline-bounded — sees it within the sweep interval, closes the sockets it owns, and `http-server-start` returns. Stopping an idle server completes in ~100ms (probe-verified; hung forever before). Covered by phase 3 of `test/integration/run-starvation-test.sh`.- **Server loop no longer starves other async tasks' socket I/O.** While at least one client was connected (e.g. a browser holding an SSE stream), the server loop's drain phase busy-spun a short-timeout native `socket-select`.src/sigil/http/server.sglmodified
(gzip default: #f) ; Opt-in automatic gzip (Accept-Encoding) (socket default: #f) ; Listening socket (running default: #f) ; Running state (clients default: '())) ; Active client connections (clients default: '()) ; Active client connections (stop-box default: #f)) ; Shared 1-slot stop signal (see below) ;;; The server record is immutable: http-server-start and the serve loop ;;; derive updated COPIES, so a caller that holds the record it passed to ;;; http-server-start never shares state with the loop's current record — ;;; a `running` field flip on one copy is invisible to the others. The ;;; stop signal therefore lives in a one-slot vector created once per ;;; make-http-server and carried BY REFERENCE through every derived copy: ;;; http-server-stop sets it, and the loop (which wakes at least every ;;; drain-sweep-interval-ms now that its waits are deadline-bounded) ;;; checks it and shuts down. #f (no box) keeps records constructed ;;; directly usable — stop then only affects the record it was called on. (define (server-stop-requested? server) (let ((box (http-server-stop-box server))) (and box (vector-ref box 0)))) ;;; Client connection state (define-struct http-client backlog: backlog timeout: timeout max-request-size: max-request-size gzip: gzip)) gzip: gzip stop-box: (vector #f))) ;;; Check if server is currently running. ;;; ;;; Stop the server gracefully. ;;; ;;; Closes the listening socket and all active client connections. ;;; Signals the serve loop through the shared stop box — the loop wakes ;;; within drain-sweep-interval-ms, closes the sockets IT owns, and ;;; http-server-start returns. This works from a sibling task holding ;;; ANY record derived from the same make-http-server (including the ;;; pre-start one), which is the goroutine-mode stop path. Sockets ;;; reachable from THIS record are also closed directly (the ;;; embedded/tick mode, where the caller drives the loop and holds the ;;; current record). (define (http-server-stop server) (: http-server? -> http-server?) (when (http-server-stop-box server) (vector-set! (http-server-stop-box server) 0 #t)) ;; Close listening socket first to stop accepting new connections (when (http-server-socket server) (socket-close (http-server-socket server))) (let ((sock (http-server-socket server))) (when (and sock (not (socket-closed? sock))) (socket-close sock))) ;; Close all active client connections (for-each (lambda (client) (let ((sock (http-client-socket client))) (when sock (when (and sock (not (socket-closed? sock))) (socket-close sock)))) (http-server-clients server)) ;; Return stopped server running: #f clients: '())) ;;; Close the sockets the serve loop currently owns and return the ;;; stopped record. Runs inside the loop when the shared stop signal is ;;; seen; tolerates sockets an embedded-mode http-server-stop already ;;; closed. (define (server-loop-shutdown server) (for-each (lambda (client) (let ((sock (http-client-socket client))) (when (and sock (not (socket-closed? sock))) (socket-close sock)))) (http-server-clients server)) (let ((sock (http-server-socket server))) (when (and sock (not (socket-closed? sock))) (socket-close sock))) (http-server server socket: #f running: #f clients: '())) ;;; Process one round of I/O (for custom event loops). (define (http-server-tick server) (: http-server? -> void?) (if (not (http-server-running server)) server (let loop ((server server)) (if (not (http-server-running server)) server (begin (await-readable (http-server-socket server)) ;; Process pending connections with guard to prevent ;; a single bad connection from crashing the server loop (let ((server* (guard (exn (else (log-server-error "Exception in connection processing" exn) server)) (process-connections server 0)))) (let drain ((s server*)) (if (null? (http-server-clients s)) ;; No more clients, wait for next connection (loop s) ;; Clients connected — suspend until the listen ;; socket or any client socket is readable (or the ;; sweep deadline passes), then do one ;; non-blocking processing round. (drain (guard (exn (else (log-server-error "Exception in drain loop" exn) s)) (begin (await-readable-any (cons (http-server-socket s) (map http-client-socket (http-server-clients s))) timeout-ms: (drain-sweep-ms s)) (process-connections s 0)))))))))))) (cond ((not (http-server-running server)) server) ;; Shared stop signal (http-server-stop from any task holding ;; a record derived from the same make-http-server): close the ;; sockets this loop owns and exit. Both waits below are ;; deadline-bounded, so this is seen within ;; drain-sweep-interval-ms of the signal. ((server-stop-requested? server) (server-loop-shutdown server)) (else ;; Wait for a connection (bounded so a stop signal is seen ;; even when the server is idle). (await-readable-any (list (http-server-socket server)) timeout-ms: drain-sweep-interval-ms) ;; Process pending connections with guard to prevent ;; a single bad connection from crashing the server loop (let ((server* (guard (exn (else (log-server-error "Exception in connection processing" exn) server)) (process-connections server 0)))) (let drain ((s server*)) (if (or (null? (http-server-clients s)) (server-stop-requested? s)) ;; No more clients (or stopping) — back to the top. (loop s) ;; Clients connected — suspend until the listen ;; socket or any client socket is readable (or the ;; sweep deadline passes), then do one ;; non-blocking processing round. (drain (guard (exn (else (log-server-error "Exception in drain loop" exn) s)) (begin (await-readable-any (cons (http-server-socket s) (map http-client-socket (http-server-clients s))) timeout-ms: (drain-sweep-ms s)) (process-connections s 0)))))))))))) ;;; Process connections using socket-select (define (process-connections server timeout-ms)test/integration/starvation-probe-main.sglmodified
;;; The harness itself only depends on timers (sleep) and native socket;;; writes, both immune to the starvation, so it can observe it.;;;;;; Output (one line): control-ms=N starved-ms=M (M = -1 when starved);;; Exit: 0 when both measurements are fast, 1 when phase 2 starves.;;; phase 3 (stop): http-server-stop is called from a SIBLING task;;; (holding the pre-start record) with no traffic in;;; flight; the suspended serve loop must wake on its;;; bounded deadline, shut down, and http-server-start;;; must return promptly.;;;;;; Output (one line): control-ms=N starved-ms=M stop-ms=K (-1 = timed out);;; Exit: 0 when all three are fast, 1 otherwise.(define-library (starvation-probe main) (import (sigil core) (wait)))))))) (define (main) (with-async ;; The http server under test. Long request timeout so the half-open ;; client is never 408-reaped during the probe (reaping would end the ;; drain spin and mask the starvation). (go (http-serve (lambda (req) (http-response/text HTTP-OK "ok")) port: http-port host: "127.0.0.1" timeout: 600000)) (go ;; let the server task start and park on its listen await (sleep 0.3) ;; The http server under test. Long request timeout so the half-open ;; client is never 408-reaped during the probe (reaping would end the ;; drain spin and mask the starvation). The pre-start record is kept so ;; phase 3 can stop the server from a sibling task (the goroutine-mode ;; stop path). (let ((srv (make-http-server (lambda (req) (http-response/text HTTP-OK "ok")) port: http-port host: "127.0.0.1" timeout: 600000)) (server-returned #f)) (with-async (go (http-server-start srv) (set! server-returned #t)) (go ;; let the server task start and park on its listen await (sleep 0.3) ;; phase 1 — control: no http clients, drain not running (let ((control-ms (measure-first-read-latency pair-port-base)) (busy #f)) ;; phase 1 — control: no http clients, drain not running (let ((control-ms (measure-first-read-latency pair-port-base)) (busy #f)) ;; phase 2 — park ONE half-open client on the http server: ;; partial request (no terminating blank line) keeps it in ;; http-server-clients, so the drain loop spins. (set! busy (%tcp-connect-sync "127.0.0.1" http-port)) (socket-write busy "GET /probe HTTP/1.1\r\nHost: probe\r\n") ;; give the server a chance to accept + enter the drain (sleep 0.3) ;; phase 2 — park ONE half-open client on the http server: ;; partial request (no terminating blank line) keeps it in ;; http-server-clients, so the drain loop spins. (set! busy (%tcp-connect-sync "127.0.0.1" http-port)) (socket-write busy "GET /probe HTTP/1.1\r\nHost: probe\r\n") ;; give the server a chance to accept + enter the drain (sleep 0.3) (let ((starved-ms (measure-first-read-latency (+ pair-port-base 1)))) (display (string-append "control-ms=" (number->string control-ms) " starved-ms=" (number->string starved-ms) "\n")) (when busy (socket-close busy)) (cond ((< control-ms 0) (display "FAIL: control measurement starved — harness broken\n") (exit 2)) ((< starved-ms 0) (display "STARVED: io-waiter never serviced while drain busy\n") (exit 1)) ((> starved-ms 2000) (display "DEGRADED: io-waiter serviced but late\n") (exit 1)) (else (display "OK: io-waiter serviced promptly under load\n") (exit 0)))))))))) (let ((starved-ms (measure-first-read-latency (+ pair-port-base 1)))) (when busy (socket-close busy)) ;; phase 3 — stop from a sibling task: the suspended loop ;; must wake on its bounded deadline and exit promptly. (let ((t-stop (current-milliseconds))) (http-server-stop srv) (let ((stop-ms (let wait () (cond (server-returned (- (current-milliseconds) t-stop)) ((> (- (current-milliseconds) t-stop) watchdog-ms) -1) (else (sleep 0.1) (wait)))))) (display (string-append "control-ms=" (number->string control-ms) " starved-ms=" (number->string starved-ms) " stop-ms=" (number->string stop-ms) "\n")) (cond ((< control-ms 0) (display "FAIL: control measurement starved — harness broken\n") (exit 2)) ((< starved-ms 0) (display "STARVED: io-waiter never serviced while drain busy\n") (exit 1)) ((> starved-ms 2000) (display "DEGRADED: io-waiter serviced but late\n") (exit 1)) ((< stop-ms 0) (display "STOP-HUNG: http-server-stop did not stop the serve loop\n") (exit 1)) ((> stop-ms 3000) (display "STOP-SLOW: serve loop exited but late\n") (exit 1)) (else (display "OK: io-waiter serviced promptly under load; stop prompt\n") (exit 0)))))))))))))