client conn-read: switch to socket-read-bytevector
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.
src/sigil/websocket/connection.sgl | 30 ++++++++++++++++++++++++------
1 file changed, 24 insertions(+), 6 deletions(-)src/sigil/websocket/connection.sglmodified
;;; Returns #t on success, #f on failure (define (validate-handshake conn expected-accept) (let ((socket (ws-connection-socket conn))) ;; Read response (accumulate until we see \r\n\r\n) ;; Read response (accumulate until we see \r\n\r\n). ;; conn-read returns bytevector for TCP / string for TLS; ;; coerce to string for HTTP header parsing. (let loop ((data "")) (let ((chunk (conn-read socket 4096))) (if (or (not chunk) (eof-object? chunk)) #f (let ((response (string-append data chunk))) (let* ((chunk-str (cond ((string? chunk) chunk) ((bytevector? chunk) (utf8->string chunk)) (else ""))) (response (string-append data chunk-str))) (if (string-contains? response "\r\n\r\n") ;; Parse headers (let* ((header-end (string-find response "\r\n\r\n")) (tls-write sock data) (socket-write sock data))) ;;; Read from socket (handles both TCP and TLS) ;;; Read from socket (handles both TCP and TLS). ;;; ;;; Returns a bytevector for plain TCP so binary WebSocket frame ;;; bytes round-trip cleanly. The legacy `socket-read` path returns ;;; a string and re-encodes any non-ASCII bytes through UTF-8, ;;; corrupting frame headers (the high opcode byte 0x81 in ;;; particular). TLS still goes through `tls-read` which handles ;;; raw bytes natively. (define (conn-read sock max-bytes) (if (tls-connection? sock) (tls-read sock max-bytes) (socket-read sock max-bytes))) (socket-read-bytevector sock max-bytes))) ;;; Close socket (handles both TCP and TLS) (define (conn-close sock) (define (receive-message conn) (let* ((socket (ws-connection-socket conn)) (buffer (ws-connection-buffer conn)) ;; Read data (blocking or non-blocking depending on socket mode) ;; Read data (blocking or non-blocking depending on socket mode). ;; conn-read returns a bytevector for TCP and a string for TLS ;; (legacy tls-read interface). Empty bytevector OR empty string ;; means "no data right now" in non-blocking mode. (chunk (conn-read socket 4096))) (cond ((or (not chunk) (eof-object? chunk)) (set-ws-connection-state! conn 'closed) 'closed) ((string=? chunk "") ((or (and (string? chunk) (string=? chunk "")) (and (bytevector? chunk) (zero? (bytevector-length chunk)))) ;; No data available, try to decode from existing buffer (try-decode-message conn)) (else