Commitdefa7755Recorded29 Apr 2026Repositorysigil-websocket

client conn-read: switch to socket-read-bytevector

Message

The legacy socket-read path returns a Sigil string built from the raw recv() bytes; running those bytes through string->utf8 (the existing receive-message path) treats them as a UTF-8 source and re-encodes high bytes. WebSocket text frame headers from a server start with 0x81 (FIN+text-opcode), which is invalid as a UTF-8 lead byte and gets normalized away — the frame decoder then rejects the frame.

Switch to socket-read-bytevector, which preserves raw bytes. Also update validate-handshake to coerce the bytevector chunk through utf8->string before string-appending into the HTTP header buffer (HTTP headers are ASCII so the round-trip is safe).

Surfaced building enclave's IRCv3-WS transport (Phase 2.1); see [[investigations/sigil-websocket-utf8-frame-corruption-2026-04-29]] for the full diagnosis.

Changed
 src/sigil/websocket/connection.sgl | 30 ++++++++++++++++++++++++------
 1 file changed, 24 insertions(+), 6 deletions(-)
Diff
src/sigil/websocket/connection.sglmodified
@@ -155,12 +155,19 @@
155
;;; Returns #t on success, #f on failure
156
(define (validate-handshake conn expected-accept)
157
(let ((socket (ws-connection-socket conn)))
158
;; Read response (accumulate until we see \r\n\r\n)
+158
;; Read response (accumulate until we see \r\n\r\n).
+159
;; conn-read returns bytevector for TCP / string for TLS;
+160
;; coerce to string for HTTP header parsing.
161
(let loop ((data ""))
162
(let ((chunk (conn-read socket 4096)))
163
(if (or (not chunk) (eof-object? chunk))
164
#f
163
(let ((response (string-append data chunk)))
+165
(let* ((chunk-str (cond
+166
((string? chunk) chunk)
+167
((bytevector? chunk)
+168
(utf8->string chunk))
+169
(else "")))
+170
(response (string-append data chunk-str)))
171
(if (string-contains? response "\r\n\r\n")
172
;; Parse headers
173
(let* ((header-end (string-find response "\r\n\r\n"))
@@ -194,11 +201,18 @@
201
(tls-write sock data)
202
(socket-write sock data)))
203
197
;;; Read from socket (handles both TCP and TLS)
+204
;;; Read from socket (handles both TCP and TLS).
+205
;;;
+206
;;; Returns a bytevector for plain TCP so binary WebSocket frame
+207
;;; bytes round-trip cleanly. The legacy `socket-read` path returns
+208
;;; a string and re-encodes any non-ASCII bytes through UTF-8,
+209
;;; corrupting frame headers (the high opcode byte 0x81 in
+210
;;; particular). TLS still goes through `tls-read` which handles
+211
;;; raw bytes natively.
212
(define (conn-read sock max-bytes)
213
(if (tls-connection? sock)
214
(tls-read sock max-bytes)
201
(socket-read sock max-bytes)))
+215
(socket-read-bytevector sock max-bytes)))
216
217
;;; Close socket (handles both TCP and TLS)
218
(define (conn-close sock)
@@ -324,13 +338,17 @@
338
(define (receive-message conn)
339
(let* ((socket (ws-connection-socket conn))
340
(buffer (ws-connection-buffer conn))
327
;; Read data (blocking or non-blocking depending on socket mode)
+341
;; Read data (blocking or non-blocking depending on socket mode).
+342
;; conn-read returns a bytevector for TCP and a string for TLS
+343
;; (legacy tls-read interface). Empty bytevector OR empty string
+344
;; means "no data right now" in non-blocking mode.
345
(chunk (conn-read socket 4096)))
346
(cond
347
((or (not chunk) (eof-object? chunk))
348
(set-ws-connection-state! conn 'closed)
349
'closed)
333
((string=? chunk "")
+350
((or (and (string? chunk) (string=? chunk ""))
+351
(and (bytevector? chunk) (zero? (bytevector-length chunk))))
352
;; No data available, try to decode from existing buffer
353
(try-decode-message conn))
354
(else