Commit10f66346Recorded11 Jul 2026Repositorysigil-http

server: drain suspends on the socket set instead of busy-spinning select

Message

While at least one client was connected (e.g. a browser holding an SSE stream), the server-loop drain busy-spun process-connections with a 10ms native socket-select. The async scheduler services socket io-waiters only when its run-queue is empty, and preemptive yield re-enqueues the spinning drain as ready, so the queue never emptied: every OTHER task's socket read starved for as long as any client stayed connected. First reads on outbound connections (IRC-style clients, websocket clients, IPC sockets) were delayed multi-second to multi-minute while timer- and channel-driven work kept running — that asymmetry is the bug's signature.

The drain now suspends on its whole socket set (listen + clients) via (sigil async) await-readable-any, waking immediately on readable data and on a bounded 1s sweep deadline so idle connections are still reaped for request timeouts (408). Without a scheduler it degrades to a bounded blocking select (previously a 10ms spin).

Requires a sigil runtime providing await-readable-any (first monorepo release carrying it).

Compiled regression probe: test/integration/run-starvation-test.sh measures a sibling socket io-waiter's first-read latency with a half-open client parked on the server (control 1ms; pre-fix never serviced within the 8s watchdog; post-fix 1ms). Verified: repo suite 144/144, streaming + keepalive/range integration suites all-pass against the new runtime.

Changed
 CHANGELOG.md                               |  24 ++++++++++++++++++++++++
 src/sigil/http/server.sgl                  |  53 +++++++++++++++++++++++++++++++++++++++++++++--------
 test/integration/run-starvation-test.sh    | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/integration/starvation-probe-main.sgl | 135 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 314 insertions(+), 8 deletions(-)
Diff
CHANGELOG.mdmodified
@@ -5,6 +5,30 @@ 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
### Fixed
+11
+12
- **Server loop no longer starves other async tasks' socket I/O.** While at
+13
least one client was connected (e.g. a browser holding an SSE stream), the
+14
server loop's drain phase busy-spun a short-timeout native `socket-select`.
+15
Because the async scheduler only polls socket io-waiters when its run queue
+16
is empty, that busy loop kept the queue perpetually non-empty and every
+17
OTHER task's socket read starved — first reads on outbound connections
+18
(IRC-style clients, websocket clients, IPC sockets) were delayed for as long
+19
as any HTTP client stayed connected, while timer- and channel-driven work
+20
kept running. The drain now suspends on its whole socket set
+21
(listen + clients) through the scheduler via `await-readable-any`, waking
+22
immediately on readable data and sweeping connection timeouts on a bounded
+23
deadline (1s), so sibling io-waiters are serviced within milliseconds.
+24
Regression test: `test/integration/run-starvation-test.sh` (compiled probe;
+25
measures a sibling socket's first-read latency with a client parked on the
+26
server).
+27
+28
**Requires** a sigil runtime providing `(sigil async)` `await-readable-any`
+29
(unreleased at the time of this entry — the first monorepo release carrying
+30
it).
+31
32
## [0.18.0] - 2026-07-10
33
34
### Added
src/sigil/http/server.sglmodified
@@ -227,15 +227,43 @@
227
(when (http-server-running server)
228
(process-connections server 0))) ; 0ms timeout = non-blocking
229
+230
;;; How long the drain loop may sleep between connection sweeps while
+231
;;; clients are connected but idle (ms). This bounds how late a request
+232
;;; timeout (408 reaping) can fire; readable data always wakes the loop
+233
;;; immediately regardless of this value. Servers configured with a
+234
;;; sub-second `timeout:` sweep at that finer interval instead (see
+235
;;; drain-sweep-ms) so reaping latency stays proportional.
+236
(define drain-sweep-interval-ms 1000)
+237
+238
(define (drain-sweep-ms server)
+239
;; (sigil math) `min` is not imported here; keep this dependency-free.
+240
(let ((server-timeout (http-server-timeout server)))
+241
(if (< server-timeout drain-sweep-interval-ms)
+242
server-timeout
+243
drain-sweep-interval-ms)))
+244
245
;;; Main server loop
246
;;;
247
;;; Cooperates with other tasks (including streaming-response goroutines)
233
;;; via `await-readable`, which yields to the async scheduler that
234
;;; http-server-start always establishes before entering the loop (it
235
;;; reuses the caller's scheduler, or self-installs one via `with-async`).
236
;;; `await-readable`/`process-connections` degrade to a blocking
237
;;; `socket-select` on their own if ever run without a scheduler, so the
238
;;; loop stays correct either way.
+248
;;; via `await-readable`/`await-readable-any`, which yield to the async
+249
;;; scheduler that http-server-start always establishes before entering
+250
;;; the loop (it reuses the caller's scheduler, or self-installs one via
+251
;;; `with-async`). Both degrade to a blocking `socket-select` on their
+252
;;; own if ever run without a scheduler, so the loop stays correct
+253
;;; either way.
+254
;;;
+255
;;; While clients are connected, the drain suspends on the WHOLE socket
+256
;;; set (listen + clients) through the scheduler rather than
+257
;;; busy-spinning a short-timeout native select. The busy-spin variant
+258
;;; kept this task perpetually runnable, and the scheduler only polls
+259
;;; socket io-waiters when its run-queue is empty — so one connected
+260
;;; client (e.g. a browser holding an SSE stream) starved every OTHER
+261
;;; task's socket I/O in the process (first reads on outbound
+262
;;; connections were delayed for as long as the client stayed
+263
;;; connected). Suspending folds this loop's sockets into the
+264
;;; scheduler's own select, so sibling io-waiters are serviced promptly.
+265
;;; The wait is deadline-bounded (`drain-sweep-interval-ms`) so idle
+266
;;; connections are still swept for request timeouts (408).
267
(define (server-loop server)
268
(if (not (http-server-running server))
269
server
@@ -255,12 +283,21 @@
283
(if (null? (http-server-clients s))
284
;; No more clients, wait for next connection
285
(loop s)
258
;; More clients - quick poll then continue
+286
;; Clients connected — suspend until the listen
+287
;; socket or any client socket is readable (or the
+288
;; sweep deadline passes), then do one
+289
;; non-blocking processing round.
290
(drain (guard (exn
291
(else
292
(log-server-error "Exception in drain loop" exn)
293
s))
263
(process-connections s 10)))))))))))
+294
(begin
+295
(await-readable-any
+296
(cons (http-server-socket s)
+297
(map http-client-socket
+298
(http-server-clients s)))
+299
timeout-ms: (drain-sweep-ms s))
+300
(process-connections s 0))))))))))))
301
302
;;; Process connections using socket-select
303
(define (process-connections server timeout-ms)
test/integration/run-starvation-test.shadded
@@ -0,0 +1,110 @@
+1
#!/usr/bin/env bash
+2
# Integration test for the server-loop drain starvation fix.
+3
#
+4
# Proves that a sibling socket io-waiter is serviced promptly while the HTTP
+5
# server has a connected (half-open) client. Before the fix, the server-loop
+6
# `drain` busy-spins process-connections while >=1 client is connected; the
+7
# async scheduler only polls socket io-waiters when its run-queue is empty,
+8
# so the sibling's first read starves (observed as multi-second to
+9
# multi-minute first-event delays for outbound sockets in real apps). After
+10
# the fix the drain suspends on the scheduler (await-readable-any), the run
+11
# queue empties, and the sibling io-waiter is serviced within milliseconds.
+12
#
+13
# Same ceremony as run-streaming-tests.sh: a loose `sigil <file>` run would
+14
# resolve imports from the RELEASED sigil-http (and the interpreter's async
+15
# path is not faithful to compiled scheduling anyway), so we build an
+16
# ephemeral bundle and run the compiled binary. The probe is self-contained
+17
# (exit 0 = serviced promptly, 1 = starved/degraded, 2 = harness failure).
+18
#
+19
# Unlike run-streaming-tests.sh, this repo's http modules are VENDORED into
+20
# the bundle (copied verbatim from src/) instead of pulled as a `from-path`
+21
# package: combining a from-path sigil-http with a from-path/redirected
+22
# sigil monorepo (needed while the `await-readable-any` scheduler primitive
+23
# is unreleased) makes the from-path package build silently produce nothing.
+24
# Vendoring sidesteps that while still compiling this repo's actual
+25
# server.sgl bytes.
+26
#
+27
# SIGIL_MONOREPO_PATH: when set, sigil-run/sigil-stdlib resolve from that
+28
# local sigil checkout (for testing against an unreleased runtime); when
+29
# unset, they resolve from the released monorepo (from-git).
+30
#
+31
# Requires: a working `sigil` toolchain, network on first run (dep fetch).
+32
# Run from anywhere:
+33
# test/integration/run-starvation-test.sh
+34
+35
set -u
+36
+37
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+38
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+39
PROBE_SRC="$SCRIPT_DIR/starvation-probe-main.sgl"
+40
+41
APP_DIR="$(mktemp -d /tmp/starvation-probe-app.XXXXXX)"
+42
BIN="$APP_DIR/build/dev/bin/starvation-probe"
+43
+44
cleanup() {
+45
rm -rf "$APP_DIR"
+46
}
+47
trap cleanup EXIT
+48
+49
echo "=== server-loop drain starvation probe ==="
+50
+51
mkdir -p "$APP_DIR/src/starvation-probe" "$APP_DIR/src/sigil/http"
+52
cp "$PROBE_SRC" "$APP_DIR/src/starvation-probe/main.sgl"
+53
# Vendor this repo's http modules (the code under test) into the bundle.
+54
for m in server request response mime; do
+55
cp "$REPO_ROOT/src/sigil/http/$m.sgl" "$APP_DIR/src/sigil/http/$m.sgl"
+56
done
+57
+58
if [ -n "${SIGIL_MONOREPO_PATH:-}" ]; then
+59
MONOREPO_DEPS="(from-path dir: \"$SIGIL_MONOREPO_PATH\" package: \"sigil-run\")
+60
(from-path dir: \"$SIGIL_MONOREPO_PATH\" package: \"sigil-stdlib\")"
+61
else
+62
MONOREPO_DEPS="(from-git url: \"codeberg:sigil/sigil\" package: \"sigil-run\" version: \"^0.17\")
+63
(from-git url: \"codeberg:sigil/sigil\" package: \"sigil-stdlib\" version: \"^0.17\")"
+64
fi
+65
+66
cat > "$APP_DIR/package.sgl" <<EOF
+67
(package
+68
name: "starvation-probe"
+69
version: "0.1.0"
+70
sigil: "^0.17"
+71
description: "Ephemeral integration bundle for the drain starvation fix"
+72
entry: '(starvation-probe main)
+73
bundle-name: "starvation-probe"
+74
configs: (list
+75
(config name: 'dev output-dir: "build/dev" static?: #f debug?: #t optimize: 0 bundle?: #t))
+76
dependencies: (list
+77
$MONOREPO_DEPS
+78
(from-git url: "codeberg:sigil/sigil-json" version: "^0.16"))
+79
+80
tasks: (list
+81
(task
+82
name: 'build
+83
description: "Compile probe + vendored http modules"
+84
steps: (list
+85
(compile-sigil-modules sources: "src/**/*.sgl")))))
+86
EOF
+87
+88
echo "--- building probe bundle ($APP_DIR) ---"
+89
(cd "$APP_DIR" \
+90
&& sigil deps install >"$APP_DIR/deps.log" 2>&1 \
+91
&& sigil build starvation-probe) || {
+92
echo "FAIL: probe build failed; deps log:"
+93
tail -20 "$APP_DIR/deps.log" 2>/dev/null
+94
exit 2
+95
}
+96
if [ ! -x "$BIN" ]; then
+97
echo "FAIL: probe binary missing after build"
+98
exit 2
+99
fi
+100
+101
echo "--- running probe (watchdog-bounded; ~10-20s) ---"
+102
"$BIN"
+103
RC=$?
+104
+105
if [ "$RC" -eq 0 ]; then
+106
echo "PASS: sibling io-waiter serviced promptly while a client was connected"
+107
else
+108
echo "FAIL: probe exit $RC (1 = starved/degraded, 2 = harness failure)"
+109
fi
+110
exit "$RC"
test/integration/starvation-probe-main.sgladded
@@ -0,0 +1,135 @@
+1
;;; (starvation-probe main) — compiled probe for the server-loop drain
+2
;;; starving sibling socket io-waiters.
+3
;;;
+4
;;; Mechanism under test: the async scheduler services socket io-waiters ONLY
+5
;;; inside poll-waiters!, which scheduler-run reaches ONLY when the run-queue
+6
;;; is empty. The sigil-http server-loop `drain` busy-spun
+7
;;; `process-connections` (a native socket-select) while >=1 client was
+8
;;; connected; preemptive yield re-enqueues it READY, so the run-queue never
+9
;;; empties and sibling io-waiters (first reads on outbound IRC-style,
+10
;;; websocket, or IPC client sockets) starve — while timer-driven work keeps
+11
;;; running. This probe measures that directly, in one compiled process:
+12
;;;
+13
;;; phase 1 (control): first-read latency on a local TCP pair with the
+14
;;; http server idle (no clients) — expect fast.
+15
;;; phase 2 (starved): same measurement with ONE half-open client parked
+16
;;; on the http server (partial request, never
+17
;;; completed) so the drain loop spins — on the broken
+18
;;; runtime the reader's io-waiter never resumes and
+19
;;; the watchdog fires.
+20
;;;
+21
;;; The harness itself only depends on timers (sleep) and native socket
+22
;;; writes, both immune to the starvation, so it can observe it.
+23
;;;
+24
;;; Output (one line): control-ms=N starved-ms=M (M = -1 when starved)
+25
;;; Exit: 0 when both measurements are fast, 1 when phase 2 starves.
+26
+27
(define-library (starvation-probe main)
+28
(import (sigil core)
+29
(sigil io)
+30
(sigil process)
+31
(sigil async)
+32
(sigil socket)
+33
(sigil time)
+34
(sigil http server)
+35
(sigil http response))
+36
+37
(export main)
+38
+39
(begin
+40
+41
(define http-port 18310)
+42
(define pair-port-base 18320)
+43
+44
;; Watchdog bound for one measurement, in ms. Generous enough for a slow
+45
;; box (the healthy latency is ~0-200ms), far below the 30s symptom.
+46
(define watchdog-ms 8000)
+47
+48
;; Measure the first-read latency of a socket io-waiter: reader goroutine
+49
;; awaits readability on the outbound half of a fresh local TCP pair; a
+50
;; timer-driven writer sends one byte 400ms later; latency = resume time
+51
;; minus send time. Returns latency in ms, or -1 if the watchdog fired.
+52
(define (measure-first-read-latency pair-port)
+53
(let ((listen (tcp-listen pair-port host: "127.0.0.1"))
+54
;; result slots: 0 = done?, 1 = t-resumed, 2 = t-sent
+55
(result (vector #f #f #f)))
+56
(let* ((out (%tcp-connect-sync "127.0.0.1" pair-port))
+57
(peer (tcp-accept listen)))
+58
(socket-set-non-blocking! out #t)
+59
;; reader — the path under test (socket io-waiter first read)
+60
(go
+61
(await-readable out)
+62
(vector-set! result 1 (current-milliseconds))
+63
(socket-read out)
+64
(vector-set! result 0 #t))
+65
;; writer — timer-driven, immune to the starvation
+66
(go
+67
(sleep 0.4)
+68
(vector-set! result 2 (current-milliseconds))
+69
(socket-write peer "x"))
+70
;; wait for the reader or the watchdog (wall-clock based: sleeps
+71
;; can lag under starvation, so count real elapsed time)
+72
(let ((t-start (current-milliseconds)))
+73
(let wait ()
+74
(cond
+75
((vector-ref result 0)
+76
(let ((latency (- (vector-ref result 1)
+77
(vector-ref result 2))))
+78
(socket-close out)
+79
(socket-close peer)
+80
(socket-close listen)
+81
latency))
+82
((> (- (current-milliseconds) t-start) watchdog-ms)
+83
(socket-close out)
+84
(socket-close peer)
+85
(socket-close listen)
+86
-1)
+87
(else
+88
(sleep 0.1)
+89
(wait))))))))
+90
+91
(define (main)
+92
(with-async
+93
;; The http server under test. Long request timeout so the half-open
+94
;; client is never 408-reaped during the probe (reaping would end the
+95
;; drain spin and mask the starvation).
+96
(go (http-serve (lambda (req) (http-response/text HTTP-OK "ok"))
+97
port: http-port
+98
host: "127.0.0.1"
+99
timeout: 600000))
+100
(go
+101
;; let the server task start and park on its listen await
+102
(sleep 0.3)
+103
+104
;; phase 1 — control: no http clients, drain not running
+105
(let ((control-ms (measure-first-read-latency pair-port-base))
+106
(busy #f))
+107
+108
;; phase 2 — park ONE half-open client on the http server:
+109
;; partial request (no terminating blank line) keeps it in
+110
;; http-server-clients, so the drain loop spins.
+111
(set! busy (%tcp-connect-sync "127.0.0.1" http-port))
+112
(socket-write busy "GET /probe HTTP/1.1\r\nHost: probe\r\n")
+113
;; give the server a chance to accept + enter the drain
+114
(sleep 0.3)
+115
+116
(let ((starved-ms (measure-first-read-latency
+117
(+ pair-port-base 1))))
+118
(display (string-append
+119
"control-ms=" (number->string control-ms)
+120
" starved-ms=" (number->string starved-ms)
+121
"\n"))
+122
(when busy (socket-close busy))
+123
(cond
+124
((< control-ms 0)
+125
(display "FAIL: control measurement starved — harness broken\n")
+126
(exit 2))
+127
((< starved-ms 0)
+128
(display "STARVED: io-waiter never serviced while drain busy\n")
+129
(exit 1))
+130
((> starved-ms 2000)
+131
(display "DEGRADED: io-waiter serviced but late\n")
+132
(exit 1))
+133
(else
+134
(display "OK: io-waiter serviced promptly under load\n")
+135
(exit 0))))))))))