Fix streaming responses in the default server (async-context trap)
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.
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(-)src/sigil/http/server.sglmodified
;;; Binds to the configured host and port, then enters the event loop. ;;; Does not return until the server is stopped. ;;; ;;; Streaming responses (`http-response/file`, SSE) spawn a goroutine via ;;; `go`, which requires a live async scheduler. To make streaming work ;;; uniformly whether or not the caller wrapped the call in `with-async`, ;;; `http-server-start` establishes its OWN scheduler (via `with-async`) ;;; when none is currently active. When a scheduler is already active — ;;; e.g. a caller ran `(go (http-server-start …))` inside `with-async` — ;;; that scheduler is reused so we don't nest a second one and break the ;;; caller's concurrent goroutines. Either way the serve loop cooperates ;;; with streaming goroutines through the same scheduler. ;;; ;;; An outer guard wraps server-loop so that any exception escaping the ;;; inner per-operation guards (e.g. from await-readable, scheduler tick ;;; internals, or other code paths not covered by process-connections "HTTP server listening on port " (number->string (http-server-port server*)) "\n")) (let resume ((s server*)) (let ((next (guard (exn (else (log-server-error "Unhandled in server loop — restarting" exn) s)) (server-loop s)))) (if (and (http-server? next) (http-server-running next)) (resume next) ; loop returned abnormally — restart next)))))))) ;; Establish an ambient scheduler if the caller didn't provide ;; one, so streaming responses (which spawn `go` goroutines) ;; work whether or not the server was started inside ;; `with-async`. When a scheduler is already current, reuse it. (if (current-scheduler) (run-server-resume-loop server*) (with-async (run-server-resume-loop server*)))))))) ;;; Run the guarded server loop, restarting on any escaping exception. ;;; Assumes an async scheduler is already active (see http-server-start). (define (run-server-resume-loop server*) (let resume ((s server*)) (let ((next (guard (exn (else (log-server-error "Unhandled in server loop — restarting" exn) s)) (server-loop s)))) (if (and (http-server? next) (http-server-running next)) (resume next) ; loop returned abnormally — restart next)))) ;;; Stop the server gracefully. ;;; ;;; Main server loop ;;; ;;; When running inside a `channel-run` context, cooperates with other ;;; tasks via `await-readable`. Otherwise uses traditional socket-select. ;;; When running inside an async scheduler, cooperates with other tasks ;;; (including streaming-response goroutines) via `await-readable`. ;;; ;;; Since http-server-start now always establishes a scheduler before ;;; entering the loop (reusing the caller's, or self-installing one via ;;; `with-async`), the cooperative branch is the path taken in practice. ;;; The socket-select blocking branch is retained as a defensive fallback ;;; for any direct/manual invocation of server-loop without a scheduler. (define (server-loop server) (if (not (http-server-running server)) server ;;; Convenience function: create and start server in one call. ;;; ;;; This is a blocking call — it runs the event loop until the server is ;;; stopped. It establishes its own async scheduler internally, so ;;; streaming responses (`http-response/file`, `http-response/sse`) work ;;; without wrapping the call in `with-async`: ;;; ;;; ```scheme ;;; ;; Plain response: ;;; (http-serve (lambda (req) (response body: "Hello!")) port: 8080) ;;; ;;; ;; Streaming a file works too — no with-async needed: ;;; (http-serve (lambda (req) (http-response/file "/path/to/image.png")) ;;; port: 8080) ;;; ``` ;;; ;;; If you already run inside `with-async` (e.g. you spawn the server with ;;; `(go (http-server-start …))` alongside other goroutines), that ;;; scheduler is reused — no nested scheduler is created. (define (http-serve handler (keys: (port 8080) (host "0.0.0.0") (backlog 128)test/integration/run-streaming-tests.shadded
#!/usr/bin/env bash# Integration test for the T1 streaming-async fix.## Proves that STREAMING responses (http-response/file and SSE) work through a# BARE `http-serve` (no with-async), and that the existing with-async consumer# pattern still works. See notes/tasks/sigil-http-t1-streaming-async.## Why the ceremony: a loose `sigil <file>` run resolves library imports from# the global dep cache (the RELEASED package), not local source — so it would# silently test the OLD code. To exercise the working-tree sigil-http we build# a tiny `from-path` bundle that links THIS repo's sigil-http, then drive it# with curl. The bundle is generated in a temp dir (NOT committed) because a# nested package.sgl inside the repo confuses `sigil test`/`sigil build`# workspace discovery.## Requires: curl, a working `sigil` toolchain, and network access on first run# (to fetch sigil-run/sigil-stdlib/sigil-json). Run from anywhere:# test/integration/run-streaming-tests.shset -uSCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"SERVER_SRC="$SCRIPT_DIR/streaming-server-main.sgl"APP_DIR="$(mktemp -d /tmp/t1-streaming-app.XXXXXX)"BIN="$APP_DIR/build/dev/bin/t1-streaming-server"ASSET="$(mktemp /tmp/t1-asset.XXXXXX.bin)"DL="$(mktemp /tmp/t1-dl.XXXXXX.bin)"SSE="$(mktemp /tmp/t1-sse.XXXXXX.txt)"BARE_PORT=18201WRAPPED_PORT=18202SRV_PID=""FAILED=0cleanup() { [ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null rm -rf "$APP_DIR" "$ASSET" "$DL" "$SSE"}trap cleanup EXITpass() { echo "PASS: $1"; }fail() { echo "FAIL: $1"; FAILED=1; }# Scaffold the ephemeral from-path consumer bundle.scaffold_app() { mkdir -p "$APP_DIR/src/t1-streaming-server" cp "$SERVER_SRC" "$APP_DIR/src/t1-streaming-server/main.sgl" cat > "$APP_DIR/package.sgl" <<EOF(package name: "t1-streaming-server" version: "0.1.0" sigil: "^0.17" description: "Ephemeral integration bundle for the T1 streaming-async fix" entry: '(t1-streaming-server main) bundle-name: "t1-streaming-server" configs: (list (config name: 'dev output-dir: "build/dev" static?: #f debug?: #t optimize: 0 bundle?: #t)) dependencies: (list (from-git url: "codeberg:sigil/sigil" package: "sigil-run" version: "^0.17") (from-git url: "codeberg:sigil/sigil" package: "sigil-stdlib" version: "^0.17") (from-git url: "codeberg:sigil/sigil-json" version: "^0.16") (from-path dir: "$REPO_ROOT" package: "sigil-http")))EOF}start_server() { local port="$1" mode="$2" "$BIN" "$port" "$ASSET" "$mode" >"$APP_DIR/server-$mode.log" 2>&1 & SRV_PID=$! local i for i in $(seq 1 60); do if curl -s --max-time 2 "http://127.0.0.1:$port/hello" >/dev/null 2>&1; then return 0 fi if ! kill -0 "$SRV_PID" 2>/dev/null; then echo " server ($mode) died on startup; log:"; cat "$APP_DIR/server-$mode.log" return 1 fi sleep 0.5 done echo " server ($mode) never became ready" return 1}stop_server() { [ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null wait "$SRV_PID" 2>/dev/null SRV_PID=""}run_mode() { local mode="$1" port="$2" echo "" echo "=== mode: $mode (port $port) ===" if ! start_server "$port" "$mode"; then fail "$mode: server startup" stop_server return fi # 1. Streaming file must come back byte-exact. if curl -s --max-time 30 "http://127.0.0.1:$port/file" -o "$DL" \ && cmp -s "$ASSET" "$DL"; then pass "$mode: http-response/file is byte-exact ($(stat -c%s "$DL") bytes)" else fail "$mode: http-response/file mismatch (got $(stat -c%s "$DL" 2>/dev/null) bytes)" fi # 2. SSE endpoint must stream all five events. curl -s --max-time 10 "http://127.0.0.1:$port/sse" -o "$SSE" local ticks ticks=$(grep -c '^event: tick' "$SSE" 2>/dev/null || echo 0) if [ "$ticks" = "5" ]; then pass "$mode: SSE streamed 5 events" else fail "$mode: SSE expected 5 events, got $ticks" fi # 3. No async-context crash should appear in the server log. if grep -q "not running in an async context" "$APP_DIR/server-$mode.log"; then fail "$mode: server logged an async-context error (streaming crashed)" else pass "$mode: no async-context error in server log" fi stop_server}echo "Scaffolding + building integration bundle (from-path sigil-http)..."scaffold_app( cd "$APP_DIR" && sigil deps install >/dev/null 2>&1 && sigil build >"$APP_DIR/build.log" 2>&1 )if [ ! -x "$BIN" ]; then echo "FAIL: bundle build did not produce $BIN" tail -20 "$APP_DIR/build.log" 2>/dev/null exit 1fi# 2.1 MB random binary asset (mirrors the production-readiness repro).head -c 2100000 /dev/urandom > "$ASSET"run_mode "bare" "$BARE_PORT" # T1: streaming works WITHOUT with-asyncrun_mode "wrapped" "$WRAPPED_PORT" # regression: existing with-async consumersecho ""if [ "$FAILED" -eq 0 ]; then echo "All streaming integration tests passed." exit 0else echo "Some streaming integration tests FAILED." exit 1fitest/integration/streaming-server-main.sgladded
;;; (t1-streaming-server main) - integration test server for the T1 fix.;;;;;; Exercises the streaming response paths (`http-response/file` and;;; `http-response/sse`), which spawn a `go` goroutine and therefore require a;;; live async scheduler. The point of T1 is that these now work from a BARE;;; `http-serve` (no surrounding `with-async`).;;;;;; Usage: t1-streaming-server <port> <asset-path> [bare|wrapped];;;;;; bare -> (http-serve handler ...) with NO with-async (default). This is;;; the case the T1 fix makes work: http-serve self-installs a;;; scheduler so the streaming `go` succeeds.;;; wrapped -> (with-async (go (http-server-start server))) — the existing;;; consumer pattern (mirrors live-crafter). Must keep working;;; unchanged (no nested/duplicate scheduler).(define-library (t1-streaming-server main) (import (sigil core) (sigil io) (sigil process) (sigil async) (sigil http server) (sigil http request) (sigil http response)) (export main) (begin ;; Asset path captured from argv at startup. (define *asset-path* (make-parameter "/dev/null")) (define (handler request) (let ((path (http-request-path request))) (cond ((string=? path "/hello") (http-response/text HTTP-OK "Hello, World!")) ;; Streaming file — the "image sometimes doesn't download" path. ((string=? path "/file") (http-response/file (*asset-path*))) ;; Server-Sent Events — five events then close. ((string=? path "/sse") (http-response/sse (lambda (send close) (let loop ((i 1)) (if (<= i 5) (begin (send "tick" (number->string i)) (loop (+ i 1))) (close)))))) (else (http-response/not-found))))) (define (main) (let* ((args (cdr (command-line))) (port (string->number (list-ref args 0))) (asset (list-ref args 1)) (mode (if (>= (length args) 3) (list-ref args 2) "bare"))) (*asset-path* asset) (if (string=? mode "wrapped") ;; Existing-consumer regression path: the server runs as a ;; goroutine inside a caller-owned scheduler. http-server-start ;; must reuse that scheduler rather than nest a second one. (let ((server (make-http-server handler port: port host: "127.0.0.1"))) (with-async (go (http-server-start server)))) ;; Bare path: no with-async at the call site (the T1 fix case). (http-serve handler port: port host: "127.0.0.1"))))))