Commit34f1f6e8Recorded10 Jul 2026Repositorysigil-web

Fix live-stream SSE endpoint 500 by passing an explicit broadcast handler

Message

stream-events-handler called http-response/sse-broadcast while omitting the broadcast handler positional, relying on it defaulting to identity. That default only exists in the . opts form (sigil-http 0.16.7+). Releases 0.16.1-0.16.6 typed the handler as a required positional; omitting it (and jumping straight to the on-connect: keyword) crashed the SSE connect with "car: expected pair" - a 500 on GET /feed, most visible when the server runs interpreted and loads one of those releases.

Pass identity explicitly. It is correct for every release: the hub carries pre-formatted SSE strings so identity is the right handler, and the . opts form accepts a leading procedure too. Bumps to 0.16.1 and adds a regression test for the live-routes -> handler call contract.

Changed
 CHANGELOG.md                 | 13 +++++++++++++
 package.sgl                  |  2 +-
 src/sigil/web/live.sgl       |  9 +++++++++
 test/test-live-sse-repro.sgl | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 76 insertions(+), 1 deletion(-)
Diff
CHANGELOG.mdmodified
@@ -5,6 +5,19 @@ All notable changes to **sigil-web** 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
## [0.16.1] - 2026-07-10
+9
+10
### Fixed
+11
+12
- Live-stream SSE endpoints (`live-routes`) no longer return `500 Internal
+13
Server Error` with `car: expected pair` when a client connects.
+14
`stream-events-handler` now passes the broadcast handler (`identity`)
+15
explicitly to `http-response/sse-broadcast` instead of relying on its default.
+16
The stream hub carries pre-formatted SSE strings, so `identity` is the correct
+17
handler; passing it explicitly keeps `GET` on a live-stream endpoint working
+18
across every `sigil-http` release, including ones that type the broadcast
+19
handler as a required positional argument.
+20
21
## [0.16.0] - 2026-07-10
22
23
### Added
package.sglmodified
@@ -9,7 +9,7 @@
9
10
(package
11
name: "sigil-web"
12
version: "0.16.0"
+12
version: "0.16.1"
13
sigil: "^0.17"
14
description: "Web application framework for Sigil"
15
url: "https://codeberg.org/sigil/sigil-web"
src/sigil/web/live.sglmodified
@@ -131,10 +131,19 @@
131
(when (channel-try-send (live-stream-rec-heartbeat-latch stream) 'started)
132
(start-sse-heartbeat! (live-stream-rec-hub stream))))
133
+134
;; The stream hub already carries pre-formatted SSE strings (live-push!
+135
;; broadcasts `ui-*` update strings verbatim), so the broadcast handler is
+136
;; `identity`. We pass it EXPLICITLY rather than relying on the default:
+137
;; some `sigil-http` releases (0.16.1 through 0.16.6) typed the handler as a
+138
;; *required* positional, and omitting it there triggers a keyword-dispatch
+139
;; crash ("car: expected pair") instead of using the identity default.
+140
;; Passing it explicitly is correct for every release: the `. opts` form
+141
;; (0.16.7+) also accepts a leading procedure as the handler.
142
(define (stream-events-handler stream)
143
(lambda (request)
144
(ensure-heartbeat! stream)
145
(http-response/sse-broadcast (live-stream-rec-hub stream)
+146
identity
147
on-connect: (live-stream-rec-on-connect stream))))
148
149
;;; Return a handler that serves the SSE endpoint for a stream or a
test/test-live-sse-repro.sgladded
@@ -0,0 +1,53 @@
+1
;; Regression test for the live-stream SSE endpoint 500 ("car: expected pair").
+2
;;
+3
;; `stream-events-handler` called `http-response/sse-broadcast` while OMITTING
+4
;; the broadcast handler positional, relying on it defaulting to the identity
+5
;; function. That default only exists in the `. opts` form of
+6
;; `http-response/sse-broadcast` (sigil-http 0.16.7+). In releases 0.16.1–0.16.6
+7
;; the signature was a `keys:` form with `handler` as a *required* positional;
+8
;; omitting it (and jumping to the `on-connect:` keyword) crashed the SSE connect
+9
;; with `type-error: car: expected pair` — a 500 on GET /feed. The fix passes the
+10
;; identity handler EXPLICITLY, which is valid for every release.
+11
;;
+12
;; NOTE: fully invoking the handler starts the lazy heartbeat via `(go ...)`,
+13
;; which needs a live scheduler and never terminates, so we can't drive it from
+14
;; a plain `sigil test`. Instead we pin the two things the fix actually depends
+15
;; on: (1) the exact `http-response/sse-broadcast` call SHAPE the handler now
+16
;; uses builds a well-formed streaming SSE response, and (2) `live-routes` wires
+17
;; a handler for both a stream and a collection. End-to-end proof (GET /feed ->
+18
;; 200 text/event-stream on a real server) is recorded in the task note.
+19
+20
(import (sigil test)
+21
(sigil core)
+22
(sigil channels)
+23
(sigil web live)
+24
(sigil web routes)
+25
(sigil http request)
+26
(sigil http response))
+27
+28
(test-group "live-stream SSE endpoint (regression: /feed 500)"
+29
;; This is the call shape stream-events-handler now emits. Under the keys-form
+30
;; sigil-http releases this exact shape is what avoids the "car: expected pair"
+31
;; crash (the handler positional is supplied); under the `. opts` form it is the
+32
;; identity handler, matching the previous default behaviour.
+33
(test "sse-broadcast with an explicit identity handler + on-connect: builds an SSE response"
+34
(let* ((hub (make-broadcast))
+35
(resp (http-response/sse-broadcast hub identity on-connect: #f)))
+36
(assert-true (http-response? resp))
+37
(assert-equal 200 (http-response-status resp))
+38
;; streaming responses carry a procedure body (write-chunk close) ...
+39
(assert-true (procedure? (http-response-body resp)))
+40
;; ... and advertise the SSE content type.
+41
(assert-equal "text/event-stream"
+42
(dict-ref (http-response-headers resp) content-type:))))
+43
+44
(test "live-routes wires a handler for a plain stream"
+45
(let* ((feed (live-stream path: "/feed"))
+46
(app (live-routes feed)))
+47
(assert-true (procedure? app))))
+48
+49
(test "live-routes wires a handler for a collection"
+50
(let* ((coll (live-collection render: (lambda (item) `(li ,(dict-ref item text:)))
+51
container: "#list" path: "/events"))
+52
(app (live-routes coll)))
+53
(assert-true (procedure? app)))))