Gate nowait's socket read on the recorded non-blocking flag, not the scheduler
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
src/sigil/websocket/connection.sgl | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
1 file changed, 61 insertions(+), 18 deletions(-)src/sigil/websocket/connection.sglmodified
(url) ; Original URL string (host) ; Host for headers (buffer mutable: #t) ; Receive buffer (bytevector) (fragments mutable: #t)) ; Accumulated message fragments (fragments mutable: #t) ; Accumulated message fragments ;; #t once ws-connect has actually put the transport in non-blocking ;; mode. RECORDED RATHER THAN RE-DERIVED: the condition ws-connect uses ;; is `(or (current-scheduler) upgrade-timeout-ms)`, so a caller that ;; passes a timeout WITHOUT a scheduler gets a non-blocking transport ;; that `(current-scheduler)` alone reports as blocking. Every read that ;; needs to know "can I touch this socket without parking?" must consult ;; this flag, never the scheduler. (non-blocking default: #f mutable: #t)) ;; Message record for received data (define-struct ws-message ;; Make the HTTP upgrade read cooperative before it can ;; wait for a peer that accepted TLS and then went quiet. (when (or (current-scheduler) upgrade-timeout-ms) (conn-set-non-blocking! socket #t)) (conn-set-non-blocking! socket #t) ;; Record what we just did. Readers must not ;; reconstruct this condition from the scheduler ;; alone — see the field comment on non-blocking. (set-ws-connection-non-blocking! conn #t)) ;; Send handshake request (conn-write socket request) ;; Validate response ;;; written in one segment followed by three seconds of silence, and the ;;; second frame surfaced 2997 ms late, with the wake frame. ;;; ;;; The intended pairing, which is non-blocking in both halves: ;;; THIS PROCEDURE IS SELF-SUFFICIENT: CALL IT ALONE, ON A LOOP. ;;; ;;; (or (ws-receive-nowait conn) ; buffered: free ;;; (and (socket-ready? sock) (ws-receive conn))) ; readable: safe ;;; It first decodes anything already buffered, and then — when the ;;; transport is non-blocking, which is what makes the read unable to park ;;; — takes exactly one socket read. A pump needs nothing else, and needs ;;; no scheduler. ;;; ;;; THIS PROCEDURE NEVER TOUCHES THE SOCKET. It decodes from the existing ;;; buffer and nothing else, so it cannot block, cannot consume socket ;;; bytes, and behaves identically whether or not a scheduler is running. ;;; `ws-receive`'s semantics are unchanged; this is purely additive. ;;; DO NOT pair it with `socket-ready?`. An earlier revision of this ;;; docstring prescribed ;;; ;;; (or (ws-receive-nowait conn) ;;; (and (socket-ready? sock) (ws-receive conn))) ;;; ;;; and that advice is unwritable over `wss://`: `socket-ready?` raises ;;; `socket-ready?: expected socket` on a TLS connection, because its ;;; native rejects anything that is not a plain socket instead of ;;; resolving the fd the way `socket-select` does. `socket-select` accepts ;;; both and is the primitive to reach for if you ever do need an external ;;; readiness gate. The two disagree about the same object; see the ;;; sigil-lib note on `native_socket_readyp`. ;;; ;;; It is also the wrong shape for a no-scheduler caller for a second ;;; reason: `ws-receive` only yields via `await-readable` when a scheduler ;;; is present, so on a partial frame without one it spins. ;;; ;;; THE RETURN CONTRACT, and the distinction is load-bearing: ;;; ;; the only way to avoid `ws-receive`'s unbounded park) is ;; otherwise deaf. ;; ;; Guarded on `(current-scheduler)` because that is exactly ;; the condition under which `ws-connect` set the socket ;; non-blocking, so the read cannot wait. It is a proxy for ;; a flag we cannot query uniformly across TCP and TLS. ;; Outside a scheduler this degrades to buffer-only, which ;; is the strongest thing that can honestly be promised ;; there. `receive-message` returns 'need-more on a ;; would-block read, which becomes #f here. (and (current-scheduler) ;; Guarded on the RECORDED non-blocking flag, because the ;; only thing that makes this read safe is that the ;; transport cannot park. ;; ;; THIS GUARD USED TO READ `(current-scheduler)`, on the ;; stated premise that a scheduler "is exactly the ;; condition under which ws-connect set the socket ;; non-blocking". THAT PREMISE IS FALSE, and it is the ;; whole of the fresh-enrollment stall: ws-connect sets ;; non-blocking on `(or (current-scheduler) ;; upgrade-timeout-ms)`, so a caller passing a timeout ;; WITHOUT a scheduler got a non-blocking transport and a ;; guard that denied it. Step 2 was then skipped forever, ;; nowait degraded to buffer-only, and a nowait-only pump ;; went permanently deaf while the peer's bytes sat unread ;; in the kernel receive queue. Measured 2026-09-04 against ;; slate `node init` (Recv-Q 121 held for 14s with the ;; process in hrtimer_nanosleep, i.e. its own poll sleep); ;; `node run`, which pumps inside `with-async`, was ;; unaffected and that is precisely why resume worked and ;; fresh enrollment did not. ;; ;; `receive-message` returns 'need-more on a would-block ;; read, which becomes #f here. (and (ws-connection-non-blocking conn) ;; A scheduler normally makes the socket nonblocking, ;; but NOWAIT must not rely on that ambient setup: a ;; caller may inherit a connection whose flag was not