server: drain suspends on the socket set instead of busy-spinning select
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.
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(-)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).## [Unreleased]### Fixed- **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`. Because the async scheduler only polls socket io-waiters when its run queue is empty, that busy loop kept the queue perpetually non-empty and every OTHER task's socket read starved — first reads on outbound connections (IRC-style clients, websocket clients, IPC sockets) were delayed for as long as any HTTP client stayed connected, while timer- and channel-driven work kept running. The drain now suspends on its whole socket set (listen + clients) through the scheduler via `await-readable-any`, waking immediately on readable data and sweeping connection timeouts on a bounded deadline (1s), so sibling io-waiters are serviced within milliseconds. Regression test: `test/integration/run-starvation-test.sh` (compiled probe; measures a sibling socket's first-read latency with a client parked on the server). **Requires** a sigil runtime providing `(sigil async)` `await-readable-any` (unreleased at the time of this entry — the first monorepo release carrying it).## [0.18.0] - 2026-07-10### Addedsrc/sigil/http/server.sglmodified
(when (http-server-running server) (process-connections server 0))) ; 0ms timeout = non-blocking ;;; How long the drain loop may sleep between connection sweeps while ;;; clients are connected but idle (ms). This bounds how late a request ;;; timeout (408 reaping) can fire; readable data always wakes the loop ;;; immediately regardless of this value. Servers configured with a ;;; sub-second `timeout:` sweep at that finer interval instead (see ;;; drain-sweep-ms) so reaping latency stays proportional. (define drain-sweep-interval-ms 1000) (define (drain-sweep-ms server) ;; (sigil math) `min` is not imported here; keep this dependency-free. (let ((server-timeout (http-server-timeout server))) (if (< server-timeout drain-sweep-interval-ms) server-timeout drain-sweep-interval-ms))) ;;; Main server loop ;;; ;;; Cooperates with other tasks (including streaming-response goroutines) ;;; via `await-readable`, which yields to the async scheduler that ;;; http-server-start always establishes before entering the loop (it ;;; reuses the caller's scheduler, or self-installs one via `with-async`). ;;; `await-readable`/`process-connections` degrade to a blocking ;;; `socket-select` on their own if ever run without a scheduler, so the ;;; loop stays correct either way. ;;; via `await-readable`/`await-readable-any`, which yield to the async ;;; scheduler that http-server-start always establishes before entering ;;; the loop (it reuses the caller's scheduler, or self-installs one via ;;; `with-async`). Both degrade to a blocking `socket-select` on their ;;; own if ever run without a scheduler, so the loop stays correct ;;; either way. ;;; ;;; While clients are connected, the drain suspends on the WHOLE socket ;;; set (listen + clients) through the scheduler rather than ;;; busy-spinning a short-timeout native select. The busy-spin variant ;;; kept this task perpetually runnable, and the scheduler only polls ;;; socket io-waiters when its run-queue is empty — so one connected ;;; client (e.g. a browser holding an SSE stream) starved every OTHER ;;; task's socket I/O in the process (first reads on outbound ;;; connections were delayed for as long as the client stayed ;;; connected). Suspending folds this loop's sockets into the ;;; scheduler's own select, so sibling io-waiters are serviced promptly. ;;; The wait is deadline-bounded (`drain-sweep-interval-ms`) so idle ;;; connections are still swept for request timeouts (408). (define (server-loop server) (if (not (http-server-running server)) server (if (null? (http-server-clients s)) ;; No more clients, wait for next connection (loop s) ;; More clients - quick poll then continue ;; 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)) (process-connections s 10))))))))))) (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/run-starvation-test.shadded
#!/usr/bin/env bash# Integration test for the server-loop drain starvation fix.## Proves that a sibling socket io-waiter is serviced promptly while the HTTP# server has a connected (half-open) client. Before the fix, the server-loop# `drain` busy-spins process-connections while >=1 client is connected; the# async scheduler only polls socket io-waiters when its run-queue is empty,# so the sibling's first read starves (observed as multi-second to# multi-minute first-event delays for outbound sockets in real apps). After# the fix the drain suspends on the scheduler (await-readable-any), the run# queue empties, and the sibling io-waiter is serviced within milliseconds.## Same ceremony as run-streaming-tests.sh: a loose `sigil <file>` run would# resolve imports from the RELEASED sigil-http (and the interpreter's async# path is not faithful to compiled scheduling anyway), so we build an# ephemeral bundle and run the compiled binary. The probe is self-contained# (exit 0 = serviced promptly, 1 = starved/degraded, 2 = harness failure).## Unlike run-streaming-tests.sh, this repo's http modules are VENDORED into# the bundle (copied verbatim from src/) instead of pulled as a `from-path`# package: combining a from-path sigil-http with a from-path/redirected# sigil monorepo (needed while the `await-readable-any` scheduler primitive# is unreleased) makes the from-path package build silently produce nothing.# Vendoring sidesteps that while still compiling this repo's actual# server.sgl bytes.## SIGIL_MONOREPO_PATH: when set, sigil-run/sigil-stdlib resolve from that# local sigil checkout (for testing against an unreleased runtime); when# unset, they resolve from the released monorepo (from-git).## Requires: a working `sigil` toolchain, network on first run (dep fetch).# Run from anywhere:# test/integration/run-starvation-test.shset -uSCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"PROBE_SRC="$SCRIPT_DIR/starvation-probe-main.sgl"APP_DIR="$(mktemp -d /tmp/starvation-probe-app.XXXXXX)"BIN="$APP_DIR/build/dev/bin/starvation-probe"cleanup() { rm -rf "$APP_DIR"}trap cleanup EXITecho "=== server-loop drain starvation probe ==="mkdir -p "$APP_DIR/src/starvation-probe" "$APP_DIR/src/sigil/http"cp "$PROBE_SRC" "$APP_DIR/src/starvation-probe/main.sgl"# Vendor this repo's http modules (the code under test) into the bundle.for m in server request response mime; do cp "$REPO_ROOT/src/sigil/http/$m.sgl" "$APP_DIR/src/sigil/http/$m.sgl"doneif [ -n "${SIGIL_MONOREPO_PATH:-}" ]; then MONOREPO_DEPS="(from-path dir: \"$SIGIL_MONOREPO_PATH\" package: \"sigil-run\") (from-path dir: \"$SIGIL_MONOREPO_PATH\" package: \"sigil-stdlib\")"else MONOREPO_DEPS="(from-git url: \"codeberg:sigil/sigil\" package: \"sigil-run\" version: \"^0.17\") (from-git url: \"codeberg:sigil/sigil\" package: \"sigil-stdlib\" version: \"^0.17\")"ficat > "$APP_DIR/package.sgl" <<EOF(package name: "starvation-probe" version: "0.1.0" sigil: "^0.17" description: "Ephemeral integration bundle for the drain starvation fix" entry: '(starvation-probe main) bundle-name: "starvation-probe" configs: (list (config name: 'dev output-dir: "build/dev" static?: #f debug?: #t optimize: 0 bundle?: #t)) dependencies: (list $MONOREPO_DEPS (from-git url: "codeberg:sigil/sigil-json" version: "^0.16")) tasks: (list (task name: 'build description: "Compile probe + vendored http modules" steps: (list (compile-sigil-modules sources: "src/**/*.sgl")))))EOFecho "--- building probe bundle ($APP_DIR) ---"(cd "$APP_DIR" \ && sigil deps install >"$APP_DIR/deps.log" 2>&1 \ && sigil build starvation-probe) || { echo "FAIL: probe build failed; deps log:" tail -20 "$APP_DIR/deps.log" 2>/dev/null exit 2}if [ ! -x "$BIN" ]; then echo "FAIL: probe binary missing after build" exit 2fiecho "--- running probe (watchdog-bounded; ~10-20s) ---""$BIN"RC=$?if [ "$RC" -eq 0 ]; then echo "PASS: sibling io-waiter serviced promptly while a client was connected"else echo "FAIL: probe exit $RC (1 = starved/degraded, 2 = harness failure)"fiexit "$RC"test/integration/starvation-probe-main.sgladded
;;; (starvation-probe main) — compiled probe for the server-loop drain;;; starving sibling socket io-waiters.;;;;;; Mechanism under test: the async scheduler services socket io-waiters ONLY;;; inside poll-waiters!, which scheduler-run reaches ONLY when the run-queue;;; is empty. The sigil-http server-loop `drain` busy-spun;;; `process-connections` (a native socket-select) while >=1 client was;;; connected; preemptive yield re-enqueues it READY, so the run-queue never;;; empties and sibling io-waiters (first reads on outbound IRC-style,;;; websocket, or IPC client sockets) starve — while timer-driven work keeps;;; running. This probe measures that directly, in one compiled process:;;;;;; phase 1 (control): first-read latency on a local TCP pair with the;;; http server idle (no clients) — expect fast.;;; phase 2 (starved): same measurement with ONE half-open client parked;;; on the http server (partial request, never;;; completed) so the drain loop spins — on the broken;;; runtime the reader's io-waiter never resumes and;;; the watchdog fires.;;;;;; 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.(define-library (starvation-probe main) (import (sigil core) (sigil io) (sigil process) (sigil async) (sigil socket) (sigil time) (sigil http server) (sigil http response)) (export main) (begin (define http-port 18310) (define pair-port-base 18320) ;; Watchdog bound for one measurement, in ms. Generous enough for a slow ;; box (the healthy latency is ~0-200ms), far below the 30s symptom. (define watchdog-ms 8000) ;; Measure the first-read latency of a socket io-waiter: reader goroutine ;; awaits readability on the outbound half of a fresh local TCP pair; a ;; timer-driven writer sends one byte 400ms later; latency = resume time ;; minus send time. Returns latency in ms, or -1 if the watchdog fired. (define (measure-first-read-latency pair-port) (let ((listen (tcp-listen pair-port host: "127.0.0.1")) ;; result slots: 0 = done?, 1 = t-resumed, 2 = t-sent (result (vector #f #f #f))) (let* ((out (%tcp-connect-sync "127.0.0.1" pair-port)) (peer (tcp-accept listen))) (socket-set-non-blocking! out #t) ;; reader — the path under test (socket io-waiter first read) (go (await-readable out) (vector-set! result 1 (current-milliseconds)) (socket-read out) (vector-set! result 0 #t)) ;; writer — timer-driven, immune to the starvation (go (sleep 0.4) (vector-set! result 2 (current-milliseconds)) (socket-write peer "x")) ;; wait for the reader or the watchdog (wall-clock based: sleeps ;; can lag under starvation, so count real elapsed time) (let ((t-start (current-milliseconds))) (let wait () (cond ((vector-ref result 0) (let ((latency (- (vector-ref result 1) (vector-ref result 2)))) (socket-close out) (socket-close peer) (socket-close listen) latency)) ((> (- (current-milliseconds) t-start) watchdog-ms) (socket-close out) (socket-close peer) (socket-close listen) -1) (else (sleep 0.1) (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) ;; 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) (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))))))))))