Set the non-blocking flag only when the mode change actually succeeded
Adversarial review found the dangerous direction still open. conn-set-non-blocking! returns #f on failure rather than raising -- a closed socket, or fcntl refusing -- and the return was discarded, so the flag could claim a mode the socket was not in.
That inverse is worse than the bug the flag fixes. nowait's TLS arm has no readiness backstop (socket-ready? raises on a TLS connection, and fd-readiness would be the wrong question for TLS anyway), so on that arm the flag is the only thing preventing a blocking read: a lying flag turns a deaf pump into a hung one. sigil-tls records its own nonblocking field only after the fd changed mode, for this exact reason; match it.
I had checked for this and cleared it, by grepping for call sites passing #f. That check was wrong: it can only find a caller ASKING to re-block, never a call that was asked to unblock and failed.
Also from the same review:
- The docstring's "CALL IT ALONE, ON A LOOP ... needs no scheduler" was false for the bare (ws-connect url) shape, which has neither a scheduler nor a timeout and so leaves the transport blocking and the pump deaf -- the very bug this change fixes, promised as fixed. The precondition is now stated with all three call shapes spelled out. The repo's own native-binary-client uses the bare shape.
- Two adjacent comments gave incompatible accounts of what is load-bearing: one said the flag is the only thing making the read safe, the next said the flag may be untrustworthy and readiness is authoritative. Rewritten to say what is actually true -- plain TCP keeps readiness as defence in depth, TLS has no backstop and depends on the flag.
- The harness reported SETUP-FAILED whenever the frame was not sent, but a client that dies before the 300ms timer leaves that flag false through no fault of the apparatus, so a genuine client failure was blamed on the setup. Split on whether the upgrade was seen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbXvyDXoTUyqV1tb1fkeFa
src/sigil/websocket/connection.sgl | 63 ++++++++++++++++++++++++++++++++++++++++++++++-----------------
test/integration/test-native-websocket-nowait-pump.mjs | 14 +++++++++++++-
2 files changed, 59 insertions(+), 18 deletions(-)src/sigil/websocket/connection.sglmodified
;; 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) ;; 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)) ;; RECORD ONLY IF THE CALL ACTUALLY SUCCEEDED. Both ;; natives report failure by RETURNING #f rather than ;; raising -- a closed socket, or fcntl refusing -- ;; so an unchecked call would let the flag claim a ;; mode the socket is not in. That direction is worse ;; than the bug this flag fixes: nowait's TLS arm has ;; no readiness backstop, so a lying flag sends it ;; into a BLOCKING mbedtls_ssl_read and the pump ;; hangs instead of merely going deaf. sigil-tls sets ;; its own `nonblocking` field only after the fd ;; changed mode, for exactly this reason; match it. (when (conn-set-non-blocking! socket #t) (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. ;;; ;;; THIS PROCEDURE IS SELF-SUFFICIENT: CALL IT ALONE, ON A LOOP. ;;; CALL IT ALONE, ON A LOOP — BUT ONLY IF THE CONNECTION WAS OPENED SO ;;; THAT ITS TRANSPORT IS NON-BLOCKING. That is the whole precondition, and ;;; it is not a formality: ;;; ;;; 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. ;;; (ws-connect url upgrade-timeout-ms: 5000) ; non-blocking -> self-sufficient ;;; (with-async … (ws-connect url) …) ; non-blocking -> self-sufficient ;;; (ws-connect url) ; BLOCKING -> BUFFER-ONLY, DEAF ;;; ;;; `ws-connect` puts the transport in non-blocking mode when a scheduler ;;; is running OR an `upgrade-timeout-ms` was supplied. With NEITHER, the ;;; socket stays blocking, this procedure cannot take its socket read ;;; without risking a park, so it correctly declines to — and a pump that ;;; calls it alone will never hear anything. Pass a timeout, or run under a ;;; scheduler, or do not use a nowait-only pump. ;;; ;;; Given that precondition: it first decodes anything already buffered, ;;; then takes exactly one socket read. A pump needs nothing else, and ;;; needs no scheduler. ;;; ;;; DO NOT pair it with `socket-ready?`. An earlier revision of this ;;; docstring prescribed ;; `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 ;; retained by a wrapper/runtime boundary. For plain ;; TCP, readiness is the authoritative no-park gate. ;; TLS retains its own nonblocking state machine and ;; may have decrypted bytes buffered above the fd. ;; A SECOND, INDEPENDENT no-park gate for plain TCP, ;; kept deliberately even though the flag above is now ;; recorded rather than inferred. Defence in depth: if ;; the flag were ever wrong, readiness still stops a ;; plain socket parking. ;; ;; TLS GETS NO SUCH BACKSTOP, and that asymmetry is ;; worth knowing: `socket-ready?` raises on a TLS ;; connection (its native rejects anything that is not ;; a plain socket), and TLS keeps its own nonblocking ;; state machine with decrypted bytes possibly ;; buffered above the fd, so fd-readiness would be the ;; wrong question anyway. On the TLS arm the flag is ;; the ONLY thing preventing a blocking read -- which ;; is why ws-connect must not set it unless the mode ;; change actually succeeded. (or (tls-connection? (ws-connection-socket conn)) (socket-ready? (ws-connection-socket conn))) (let ((result (receive-message conn)))test/integration/test-native-websocket-nowait-pump.mjsmodified
}let sentFrame = false;let upgradeSeen = false;const server = net.createServer((socket) => { let buffer = Buffer.alloc(0); socket.on("data", (chunk) => { "ascii", )); buffer = Buffer.alloc(0); upgradeSeen = true; setTimeout(() => { socket.write(textFrame(PAYLOAD)); sentFrame = true;// Positive control on the apparatus: if the server never got far enough to// send the frame, the client's silence says nothing about the client. That// must read as a setup failure, not as a regression.//// But "frame not sent" has two causes and they mean opposite things. The// frame goes out on a 300ms timer, so a client that dies BEFORE then -- a// failed upgrade, a missing dependency, a compile error in connection.sgl --// closes the socket first and leaves sentFrame false through no fault of the// apparatus. Reporting that as SETUP-FAILED would blame the harness for a// genuine client failure, so split the two on whether the upgrade completed.if (!sentFrame) { throw new Error(`SETUP-FAILED: server never sent its frame, so this run tests nothing\nstdout:\n${stdout}\nstderr:\n${stderr}`); const reached = upgradeSeen ? "the client connected and completed its upgrade, then exited before the frame timer ran -- this is a CLIENT failure, not a setup failure" : "the server never saw a completed upgrade request, so the apparatus never got far enough to test anything (SETUP-FAILED)"; throw new Error(`no frame was sent: ${reached}\nstatus: ${status}\nstdout:\n${stdout}\nstderr:\n${stderr}`);}if (status !== 0 || !stdout.includes("native-websocket-nowait-pump-ok")) { throw new Error(`nowait pump client failed (${status})\nstdout:\n${stdout}\nstderr:\n${stderr}`);