Commit2ca5ca75Recorded4 Sep 2026Repositorysigil-websocket

Gate nowait's socket read on the recorded non-blocking flag, not the scheduler

Message

ws-receive-nowait takes exactly one socket read when it can do so without parking, and decided that from (current-scheduler). The stated premise was that a scheduler is "exactly the condition under which ws-connect set the socket non-blocking". It is not: ws-connect sets non-blocking on (or (current-scheduler) upgrade-timeout-ms).

So a caller that passes a bounded upgrade timeout and runs no scheduler gets a non-blocking transport and a guard that denies it. Step 2 never runs, nowait degrades to buffer-only, and a nowait-only pump is permanently deaf: the peer's frames sit unread in the kernel receive queue forever.

That is the slate fresh-enrollment stall. slate node init pumps with no scheduler and passes upgrade-timeout-ms: 5000; measured Recv-Q 121 held for 14s with the process parked in its own poll sleep (hrtimer_nanosleep), never in a select. slate node run pumps inside with-async, so its reads were allowed -- which is exactly why credential resume worked and fresh enrollment did not.

Record what ws-connect actually did on the connection and consult that. No timeout is weakened and no wait is removed; the read this re-enables is the non-blocking one the procedure was already written to perform.

Also correct two claims in the docstring that its own body contradicts: "THIS PROCEDURE NEVER TOUCHES THE SOCKET" (step 2 does), and a prescribed pairing with socket-ready?, which is unwritable over wss:// because socket-ready?'s native rejects TLS connections rather than resolving their fd the way socket-select does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbXvyDXoTUyqV1tb1fkeFa

Changed
 src/sigil/websocket/connection.sgl | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
 1 file changed, 61 insertions(+), 18 deletions(-)
Diff
src/sigil/websocket/connection.sglmodified
@@ -94,7 +94,15 @@
94
(url) ; Original URL string
95
(host) ; Host for headers
96
(buffer mutable: #t) ; Receive buffer (bytevector)
−97
(fragments mutable: #t)) ; Accumulated message fragments
+97
(fragments mutable: #t) ; Accumulated message fragments
+98
;; #t once ws-connect has actually put the transport in non-blocking
+99
;; mode. RECORDED RATHER THAN RE-DERIVED: the condition ws-connect uses
+100
;; is `(or (current-scheduler) upgrade-timeout-ms)`, so a caller that
+101
;; passes a timeout WITHOUT a scheduler gets a non-blocking transport
+102
;; that `(current-scheduler)` alone reports as blocking. Every read that
+103
;; needs to know "can I touch this socket without parking?" must consult
+104
;; this flag, never the scheduler.
+105
(non-blocking default: #f mutable: #t))
106
107
;; Message record for received data
108
(define-struct ws-message
@@ -393,7 +401,11 @@
401
;; Make the HTTP upgrade read cooperative before it can
402
;; wait for a peer that accepted TLS and then went quiet.
403
(when (or (current-scheduler) upgrade-timeout-ms)
−396
(conn-set-non-blocking! socket #t))
+404
(conn-set-non-blocking! socket #t)
+405
;; Record what we just did. Readers must not
+406
;; reconstruct this condition from the scheduler
+407
;; alone — see the field comment on non-blocking.
+408
(set-ws-connection-non-blocking! conn #t))
409
;; Send handshake request
410
(conn-write socket request)
411
;; Validate response
@@ -690,15 +702,30 @@
702
;;; written in one segment followed by three seconds of silence, and the
703
;;; second frame surfaced 2997 ms late, with the wake frame.
704
;;;
−693
;;; The intended pairing, which is non-blocking in both halves:
+705
;;; THIS PROCEDURE IS SELF-SUFFICIENT: CALL IT ALONE, ON A LOOP.
706
;;;
−695
;;; (or (ws-receive-nowait conn) ; buffered: free
−696
;;; (and (socket-ready? sock) (ws-receive conn))) ; readable: safe
+707
;;; It first decodes anything already buffered, and then — when the
+708
;;; transport is non-blocking, which is what makes the read unable to park
+709
;;; — takes exactly one socket read. A pump needs nothing else, and needs
+710
;;; no scheduler.
711
;;;
−698
;;; THIS PROCEDURE NEVER TOUCHES THE SOCKET. It decodes from the existing
−699
;;; buffer and nothing else, so it cannot block, cannot consume socket
−700
;;; bytes, and behaves identically whether or not a scheduler is running.
−701
;;; `ws-receive`'s semantics are unchanged; this is purely additive.
+712
;;; DO NOT pair it with `socket-ready?`. An earlier revision of this
+713
;;; docstring prescribed
+714
;;;
+715
;;; (or (ws-receive-nowait conn)
+716
;;; (and (socket-ready? sock) (ws-receive conn)))
+717
;;;
+718
;;; and that advice is unwritable over `wss://`: `socket-ready?` raises
+719
;;; `socket-ready?: expected socket` on a TLS connection, because its
+720
;;; native rejects anything that is not a plain socket instead of
+721
;;; resolving the fd the way `socket-select` does. `socket-select` accepts
+722
;;; both and is the primitive to reach for if you ever do need an external
+723
;;; readiness gate. The two disagree about the same object; see the
+724
;;; sigil-lib note on `native_socket_readyp`.
+725
;;;
+726
;;; It is also the wrong shape for a no-scheduler caller for a second
+727
;;; reason: `ws-receive` only yields via `await-readable` when a scheduler
+728
;;; is present, so on a partial frame without one it spins.
729
;;;
730
;;; THE RETURN CONTRACT, and the distinction is load-bearing:
731
;;;
@@ -756,15 +783,31 @@
783
;; the only way to avoid `ws-receive`'s unbounded park) is
784
;; otherwise deaf.
785
;;
−759
;; Guarded on `(current-scheduler)` because that is exactly
−760
;; the condition under which `ws-connect` set the socket
−761
;; non-blocking, so the read cannot wait. It is a proxy for
−762
;; a flag we cannot query uniformly across TCP and TLS.
−763
;; Outside a scheduler this degrades to buffer-only, which
−764
;; is the strongest thing that can honestly be promised
−765
;; there. `receive-message` returns 'need-more on a
−766
;; would-block read, which becomes #f here.
−767
(and (current-scheduler)
+786
;; Guarded on the RECORDED non-blocking flag, because the
+787
;; only thing that makes this read safe is that the
+788
;; transport cannot park.
+789
;;
+790
;; THIS GUARD USED TO READ `(current-scheduler)`, on the
+791
;; stated premise that a scheduler "is exactly the
+792
;; condition under which ws-connect set the socket
+793
;; non-blocking". THAT PREMISE IS FALSE, and it is the
+794
;; whole of the fresh-enrollment stall: ws-connect sets
+795
;; non-blocking on `(or (current-scheduler)
+796
;; upgrade-timeout-ms)`, so a caller passing a timeout
+797
;; WITHOUT a scheduler got a non-blocking transport and a
+798
;; guard that denied it. Step 2 was then skipped forever,
+799
;; nowait degraded to buffer-only, and a nowait-only pump
+800
;; went permanently deaf while the peer's bytes sat unread
+801
;; in the kernel receive queue. Measured 2026-09-04 against
+802
;; slate `node init` (Recv-Q 121 held for 14s with the
+803
;; process in hrtimer_nanosleep, i.e. its own poll sleep);
+804
;; `node run`, which pumps inside `with-async`, was
+805
;; unaffected and that is precisely why resume worked and
+806
;; fresh enrollment did not.
+807
;;
+808
;; `receive-message` returns 'need-more on a would-block
+809
;; read, which becomes #f here.
+810
(and (ws-connection-non-blocking conn)
811
;; A scheduler normally makes the socket nonblocking,
812
;; but NOWAIT must not rely on that ambient setup: a
813
;; caller may inherit a connection whose flag was not