Commit0741e501Recorded10 Jul 2026Repositorysigil-http

Fix streaming responses in the default server (async-context trap)

Message

http-server-start now establishes its own async scheduler (via with-async) when none is active, so streaming responses (http-response/file, SSE) — which spawn a goroutine with go — work from a bare http-serve without wrapping the call in with-async. When a scheduler is already current (e.g. a caller runs (go (http-server-start ...)) inside with-async), it is reused, so existing consumers are unaffected and no nested scheduler is installed.

Previously a bare http-serve returning a streaming body threw "go: not running in an async context" at send time; the outer guard restarted the loop and the client got an empty/aborted response — a likely cause of large assets served via http-response/file intermittently failing to download.

Corrects the http-serve docstring to lead with the bare (no with-async) call and document with-async as the advanced case. Adds an integration test (test/integration) that builds a from-path bundle of this repo's sigil-http and verifies, over the wire: bare http-serve streams a 2.1MB file byte-exact and an SSE endpoint; a with-async-wrapped server still works.

Changed
 src/sigil/http/server.sgl                  |  67 +++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
 test/integration/run-streaming-tests.sh    | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/integration/streaming-server-main.sgl |  66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 272 insertions(+), 13 deletions(-)
Diff
src/sigil/http/server.sglmodified
@@ -129,6 +129,16 @@
129
;;; Binds to the configured host and port, then enters the event loop.
130
;;; Does not return until the server is stopped.
131
;;;
+132
;;; Streaming responses (`http-response/file`, SSE) spawn a goroutine via
+133
;;; `go`, which requires a live async scheduler. To make streaming work
+134
;;; uniformly whether or not the caller wrapped the call in `with-async`,
+135
;;; `http-server-start` establishes its OWN scheduler (via `with-async`)
+136
;;; when none is currently active. When a scheduler is already active —
+137
;;; e.g. a caller ran `(go (http-server-start …))` inside `with-async` —
+138
;;; that scheduler is reused so we don't nest a second one and break the
+139
;;; caller's concurrent goroutines. Either way the serve loop cooperates
+140
;;; with streaming goroutines through the same scheduler.
+141
;;;
142
;;; An outer guard wraps server-loop so that any exception escaping the
143
;;; inner per-operation guards (e.g. from await-readable, scheduler tick
144
;;; internals, or other code paths not covered by process-connections
@@ -154,17 +164,28 @@
164
"HTTP server listening on port "
165
(number->string (http-server-port server*))
166
"\n"))
157
(let resume ((s server*))
158
(let ((next (guard (exn
159
(else
160
(log-server-error
161
"Unhandled in server loop — restarting"
162
exn)
163
s))
164
(server-loop s))))
165
(if (and (http-server? next) (http-server-running next))
166
(resume next) ; loop returned abnormally — restart
167
next))))))))
+167
;; Establish an ambient scheduler if the caller didn't provide
+168
;; one, so streaming responses (which spawn `go` goroutines)
+169
;; work whether or not the server was started inside
+170
;; `with-async`. When a scheduler is already current, reuse it.
+171
(if (current-scheduler)
+172
(run-server-resume-loop server*)
+173
(with-async (run-server-resume-loop server*))))))))
+174
+175
;;; Run the guarded server loop, restarting on any escaping exception.
+176
;;; Assumes an async scheduler is already active (see http-server-start).
+177
(define (run-server-resume-loop server*)
+178
(let resume ((s server*))
+179
(let ((next (guard (exn
+180
(else
+181
(log-server-error
+182
"Unhandled in server loop — restarting"
+183
exn)
+184
s))
+185
(server-loop s))))
+186
(if (and (http-server? next) (http-server-running next))
+187
(resume next) ; loop returned abnormally — restart
+188
next))))
189
190
;;; Stop the server gracefully.
191
;;;
@@ -195,8 +216,14 @@
216
217
;;; Main server loop
218
;;;
198
;;; When running inside a `channel-run` context, cooperates with other
199
;;; tasks via `await-readable`. Otherwise uses traditional socket-select.
+219
;;; When running inside an async scheduler, cooperates with other tasks
+220
;;; (including streaming-response goroutines) via `await-readable`.
+221
;;;
+222
;;; Since http-server-start now always establishes a scheduler before
+223
;;; entering the loop (reusing the caller's, or self-installing one via
+224
;;; `with-async`), the cooperative branch is the path taken in practice.
+225
;;; The socket-select blocking branch is retained as a defensive fallback
+226
;;; for any direct/manual invocation of server-loop without a scheduler.
227
(define (server-loop server)
228
(if (not (http-server-running server))
229
server
@@ -661,9 +688,23 @@
688
689
;;; Convenience function: create and start server in one call.
690
;;;
+691
;;; This is a blocking call — it runs the event loop until the server is
+692
;;; stopped. It establishes its own async scheduler internally, so
+693
;;; streaming responses (`http-response/file`, `http-response/sse`) work
+694
;;; without wrapping the call in `with-async`:
+695
;;;
696
;;; ```scheme
+697
;;; ;; Plain response:
698
;;; (http-serve (lambda (req) (response body: "Hello!")) port: 8080)
+699
;;;
+700
;;; ;; Streaming a file works too — no with-async needed:
+701
;;; (http-serve (lambda (req) (http-response/file "/path/to/image.png"))
+702
;;; port: 8080)
703
;;; ```
+704
;;;
+705
;;; If you already run inside `with-async` (e.g. you spawn the server with
+706
;;; `(go (http-server-start …))` alongside other goroutines), that
+707
;;; scheduler is reused — no nested scheduler is created.
708
(define (http-serve handler (keys: (port 8080)
709
(host "0.0.0.0")
710
(backlog 128)
test/integration/run-streaming-tests.shadded
@@ -0,0 +1,152 @@
+1
#!/usr/bin/env bash
+2
# Integration test for the T1 streaming-async fix.
+3
#
+4
# Proves that STREAMING responses (http-response/file and SSE) work through a
+5
# BARE `http-serve` (no with-async), and that the existing with-async consumer
+6
# pattern still works. See notes/tasks/sigil-http-t1-streaming-async.
+7
#
+8
# Why the ceremony: a loose `sigil <file>` run resolves library imports from
+9
# the global dep cache (the RELEASED package), not local source — so it would
+10
# silently test the OLD code. To exercise the working-tree sigil-http we build
+11
# a tiny `from-path` bundle that links THIS repo's sigil-http, then drive it
+12
# with curl. The bundle is generated in a temp dir (NOT committed) because a
+13
# nested package.sgl inside the repo confuses `sigil test`/`sigil build`
+14
# workspace discovery.
+15
#
+16
# Requires: curl, a working `sigil` toolchain, and network access on first run
+17
# (to fetch sigil-run/sigil-stdlib/sigil-json). Run from anywhere:
+18
# test/integration/run-streaming-tests.sh
+19
+20
set -u
+21
+22
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+23
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+24
SERVER_SRC="$SCRIPT_DIR/streaming-server-main.sgl"
+25
+26
APP_DIR="$(mktemp -d /tmp/t1-streaming-app.XXXXXX)"
+27
BIN="$APP_DIR/build/dev/bin/t1-streaming-server"
+28
ASSET="$(mktemp /tmp/t1-asset.XXXXXX.bin)"
+29
DL="$(mktemp /tmp/t1-dl.XXXXXX.bin)"
+30
SSE="$(mktemp /tmp/t1-sse.XXXXXX.txt)"
+31
BARE_PORT=18201
+32
WRAPPED_PORT=18202
+33
SRV_PID=""
+34
FAILED=0
+35
+36
cleanup() {
+37
[ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null
+38
rm -rf "$APP_DIR" "$ASSET" "$DL" "$SSE"
+39
}
+40
trap cleanup EXIT
+41
+42
pass() { echo "PASS: $1"; }
+43
fail() { echo "FAIL: $1"; FAILED=1; }
+44
+45
# Scaffold the ephemeral from-path consumer bundle.
+46
scaffold_app() {
+47
mkdir -p "$APP_DIR/src/t1-streaming-server"
+48
cp "$SERVER_SRC" "$APP_DIR/src/t1-streaming-server/main.sgl"
+49
cat > "$APP_DIR/package.sgl" <<EOF
+50
(package
+51
name: "t1-streaming-server"
+52
version: "0.1.0"
+53
sigil: "^0.17"
+54
description: "Ephemeral integration bundle for the T1 streaming-async fix"
+55
entry: '(t1-streaming-server main)
+56
bundle-name: "t1-streaming-server"
+57
configs: (list
+58
(config name: 'dev output-dir: "build/dev" static?: #f debug?: #t optimize: 0 bundle?: #t))
+59
dependencies: (list
+60
(from-git url: "codeberg:sigil/sigil" package: "sigil-run" version: "^0.17")
+61
(from-git url: "codeberg:sigil/sigil" package: "sigil-stdlib" version: "^0.17")
+62
(from-git url: "codeberg:sigil/sigil-json" version: "^0.16")
+63
(from-path dir: "$REPO_ROOT" package: "sigil-http")))
+64
EOF
+65
}
+66
+67
start_server() {
+68
local port="$1" mode="$2"
+69
"$BIN" "$port" "$ASSET" "$mode" >"$APP_DIR/server-$mode.log" 2>&1 &
+70
SRV_PID=$!
+71
local i
+72
for i in $(seq 1 60); do
+73
if curl -s --max-time 2 "http://127.0.0.1:$port/hello" >/dev/null 2>&1; then
+74
return 0
+75
fi
+76
if ! kill -0 "$SRV_PID" 2>/dev/null; then
+77
echo " server ($mode) died on startup; log:"; cat "$APP_DIR/server-$mode.log"
+78
return 1
+79
fi
+80
sleep 0.5
+81
done
+82
echo " server ($mode) never became ready"
+83
return 1
+84
}
+85
+86
stop_server() {
+87
[ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null
+88
wait "$SRV_PID" 2>/dev/null
+89
SRV_PID=""
+90
}
+91
+92
run_mode() {
+93
local mode="$1" port="$2"
+94
echo ""
+95
echo "=== mode: $mode (port $port) ==="
+96
if ! start_server "$port" "$mode"; then
+97
fail "$mode: server startup"
+98
stop_server
+99
return
+100
fi
+101
+102
# 1. Streaming file must come back byte-exact.
+103
if curl -s --max-time 30 "http://127.0.0.1:$port/file" -o "$DL" \
+104
&& cmp -s "$ASSET" "$DL"; then
+105
pass "$mode: http-response/file is byte-exact ($(stat -c%s "$DL") bytes)"
+106
else
+107
fail "$mode: http-response/file mismatch (got $(stat -c%s "$DL" 2>/dev/null) bytes)"
+108
fi
+109
+110
# 2. SSE endpoint must stream all five events.
+111
curl -s --max-time 10 "http://127.0.0.1:$port/sse" -o "$SSE"
+112
local ticks
+113
ticks=$(grep -c '^event: tick' "$SSE" 2>/dev/null || echo 0)
+114
if [ "$ticks" = "5" ]; then
+115
pass "$mode: SSE streamed 5 events"
+116
else
+117
fail "$mode: SSE expected 5 events, got $ticks"
+118
fi
+119
+120
# 3. No async-context crash should appear in the server log.
+121
if grep -q "not running in an async context" "$APP_DIR/server-$mode.log"; then
+122
fail "$mode: server logged an async-context error (streaming crashed)"
+123
else
+124
pass "$mode: no async-context error in server log"
+125
fi
+126
+127
stop_server
+128
}
+129
+130
echo "Scaffolding + building integration bundle (from-path sigil-http)..."
+131
scaffold_app
+132
( cd "$APP_DIR" && sigil deps install >/dev/null 2>&1 && sigil build >"$APP_DIR/build.log" 2>&1 )
+133
if [ ! -x "$BIN" ]; then
+134
echo "FAIL: bundle build did not produce $BIN"
+135
tail -20 "$APP_DIR/build.log" 2>/dev/null
+136
exit 1
+137
fi
+138
+139
# 2.1 MB random binary asset (mirrors the production-readiness repro).
+140
head -c 2100000 /dev/urandom > "$ASSET"
+141
+142
run_mode "bare" "$BARE_PORT" # T1: streaming works WITHOUT with-async
+143
run_mode "wrapped" "$WRAPPED_PORT" # regression: existing with-async consumers
+144
+145
echo ""
+146
if [ "$FAILED" -eq 0 ]; then
+147
echo "All streaming integration tests passed."
+148
exit 0
+149
else
+150
echo "Some streaming integration tests FAILED."
+151
exit 1
+152
fi
test/integration/streaming-server-main.sgladded
@@ -0,0 +1,66 @@
+1
;;; (t1-streaming-server main) - integration test server for the T1 fix.
+2
;;;
+3
;;; Exercises the streaming response paths (`http-response/file` and
+4
;;; `http-response/sse`), which spawn a `go` goroutine and therefore require a
+5
;;; live async scheduler. The point of T1 is that these now work from a BARE
+6
;;; `http-serve` (no surrounding `with-async`).
+7
;;;
+8
;;; Usage: t1-streaming-server <port> <asset-path> [bare|wrapped]
+9
;;;
+10
;;; bare -> (http-serve handler ...) with NO with-async (default). This is
+11
;;; the case the T1 fix makes work: http-serve self-installs a
+12
;;; scheduler so the streaming `go` succeeds.
+13
;;; wrapped -> (with-async (go (http-server-start server))) — the existing
+14
;;; consumer pattern (mirrors live-crafter). Must keep working
+15
;;; unchanged (no nested/duplicate scheduler).
+16
+17
(define-library (t1-streaming-server main)
+18
(import (sigil core)
+19
(sigil io)
+20
(sigil process)
+21
(sigil async)
+22
(sigil http server)
+23
(sigil http request)
+24
(sigil http response))
+25
+26
(export main)
+27
+28
(begin
+29
+30
;; Asset path captured from argv at startup.
+31
(define *asset-path* (make-parameter "/dev/null"))
+32
+33
(define (handler request)
+34
(let ((path (http-request-path request)))
+35
(cond
+36
((string=? path "/hello")
+37
(http-response/text HTTP-OK "Hello, World!"))
+38
;; Streaming file — the "image sometimes doesn't download" path.
+39
((string=? path "/file")
+40
(http-response/file (*asset-path*)))
+41
;; Server-Sent Events — five events then close.
+42
((string=? path "/sse")
+43
(http-response/sse
+44
(lambda (send close)
+45
(let loop ((i 1))
+46
(if (<= i 5)
+47
(begin
+48
(send "tick" (number->string i))
+49
(loop (+ i 1)))
+50
(close))))))
+51
(else (http-response/not-found)))))
+52
+53
(define (main)
+54
(let* ((args (cdr (command-line)))
+55
(port (string->number (list-ref args 0)))
+56
(asset (list-ref args 1))
+57
(mode (if (>= (length args) 3) (list-ref args 2) "bare")))
+58
(*asset-path* asset)
+59
(if (string=? mode "wrapped")
+60
;; Existing-consumer regression path: the server runs as a
+61
;; goroutine inside a caller-owned scheduler. http-server-start
+62
;; must reuse that scheduler rather than nest a second one.
+63
(let ((server (make-http-server handler port: port host: "127.0.0.1")))
+64
(with-async (go (http-server-start server))))
+65
;; Bare path: no with-async at the call site (the T1 fix case).
+66
(http-serve handler port: port host: "127.0.0.1"))))))