Add a regression test for the nowait-only pump with no scheduler
Drives the shape that stalled: no with-async, a bounded upgrade-timeout-ms, and a poll loop whose only read primitive is ws-receive-nowait. The server sends its text frame AFTER the 101 rather than coalesced with it, so the payload is reachable only through nowait's own non-blocking socket read -- a coalesced frame lands in the connection buffer during the handshake and would pass even with the read disabled, which is what hid this defect.
Plain ws:// on purpose. The guard was never about TLS, so the test needs no certificate and no openssl on PATH (openssl is absent from the bare PATH on this estate's hosts, which is enough on its own to keep it out of a gate).
The harness distinguishes SETUP-FAILED from a regression: if the server never managed to send its frame, the client's silence says nothing about the client and must not be scored as a pass or as a failure of the code under test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AbXvyDXoTUyqV1tb1fkeFa
test/integration/nowait-pump-client.sgl | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/integration/test-native-websocket-nowait-pump.mjs | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/integration/test-native-websocket-nowait-pump.sh | 3 +++
3 files changed, 150 insertions(+)test/integration/nowait-pump-client.sgladded
;;; Regression: a nowait-only pump with NO scheduler must still hear the peer.;;;;;; This is the shape `slate node init` uses, and the shape that stalled: no;;; `with-async`, a bounded `upgrade-timeout-ms`, and a poll loop whose only;;; read primitive is `ws-receive-nowait`. `ws-connect` puts the transport in;;; non-blocking mode whenever a scheduler OR an upgrade timeout is present,;;; so nowait's single socket read is safe here -- but it used to be gated on;;; `(current-scheduler)` alone, which denied it, and the pump went deaf while;;; the server's frame sat unread in the kernel receive queue.;;;;;; The server sends its frame AFTER the 101, deliberately not coalesced with;;; it: a coalesced frame lands in `ws-connection-buffer` during the handshake;;; and would be decodable without any socket read at all, which is exactly;;; the case that hid this defect.;;;;;; Nothing here is TLS-specific. The guard was never about TLS, and this runs;;; over plain `ws://` so it needs no certificate and no `openssl` on PATH.(import (sigil core) (sigil time) (sigil websocket))(define port (or (and (getenv "SIGIL_WS_TEST_PORT") (string->number (getenv "SIGIL_WS_TEST_PORT"))) (error "nowait pump client requires SIGIL_WS_TEST_PORT")))(define conn (ws-connect (string-append "ws://127.0.0.1:" (number->string port) "/") connect-timeout-ms: 5000 handshake-timeout-ms: 5000 upgrade-timeout-ms: 5000))(unless conn (error "nowait pump client: upgrade did not complete"));; Deliberately NOT inside with-async: the absence of a scheduler is the;; condition under test, so wrapping this would silently retire the regression.(define deadline (+ (current-milliseconds) 5000))(let loop () (let ((msg (ws-receive-nowait conn))) (cond ((ws-message? msg) (unless (string=? (ws-message-data msg) "HELLO-FROM-SERVER") (error "nowait pump client: unexpected payload" (ws-message-data msg))) (display "native-websocket-nowait-pump-ok") (newline)) ((eq? msg 'closed) (error "nowait pump client: connection closed before the frame arrived")) ((> (current-milliseconds) deadline) (error "nowait pump client: no frame after 5000ms (nowait pump went deaf)")) (else (sleep 0.05) (loop)))))test/integration/test-native-websocket-nowait-pump.mjsadded
// Regression harness for the nowait-only pump with no scheduler.//// Completes a real WebSocket upgrade, then sends ONE text frame after a delay// so it cannot be coalesced with the 101. A client that never issues a socket// read will never see it; the frame is only reachable through nowait's own// single non-blocking read.//// Plain TCP on purpose: the defect was in a `(current-scheduler)` guard, not// in TLS, so this needs no certificate and no `openssl` on PATH.import crypto from "node:crypto";import net from "node:net";import path from "node:path";import { fileURLToPath } from "node:url";import { spawn } from "node:child_process";const here = path.dirname(fileURLToPath(import.meta.url));const repo = path.resolve(here, "../..");const PAYLOAD = "HELLO-FROM-SERVER";const FRAME_DELAY_MS = 300;function textFrame(text) { const payload = Buffer.from(text, "utf8"); if (payload.length >= 126) throw new Error("fixture payload is unexpectedly large"); return Buffer.concat([Buffer.from([0x81, payload.length]), payload]);}let sentFrame = false;const server = net.createServer((socket) => { let buffer = Buffer.alloc(0); socket.on("data", (chunk) => { buffer = Buffer.concat([buffer, chunk]); const end = buffer.indexOf("\r\n\r\n"); if (end < 0) return; const request = buffer.subarray(0, end + 4).toString("ascii"); const key = request.match(/Sec-WebSocket-Key:\s*([^\r\n]+)/i)?.[1]; if (!key) throw new Error("client handshake omitted Sec-WebSocket-Key"); const accept = crypto.createHash("sha1") .update(`${key.trim()}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) .digest("base64"); // The 101 goes out ALONE. Coalescing the frame with it would put the // payload in the connection buffer during the handshake and the test // would pass without any socket read ever happening. socket.write(Buffer.from( `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, "ascii", )); buffer = Buffer.alloc(0); setTimeout(() => { socket.write(textFrame(PAYLOAD)); sentFrame = true; }, FRAME_DELAY_MS); }); socket.on("error", () => {});});let resolvePort;const listening = new Promise((resolve) => { resolvePort = resolve; });server.listen(0, "127.0.0.1", () => resolvePort(server.address().port));const port = await listening;const dependencyRoots = [ "sigil-stdlib", "sigil-socket", "sigil-tls", "sigil-crypto",].map((name) => path.join(repo, ".sigil", "deps", name, "src"));const args = [ "-L", path.join(repo, "build/dev/lib"), ...dependencyRoots.flatMap((root) => ["-L", root]), path.join(here, "nowait-pump-client.sgl"),];const child = spawn("sigil", args, { cwd: "/tmp", env: { ...process.env, SIGIL_WS_TEST_PORT: String(port) }, stdio: ["ignore", "pipe", "pipe"],});let stdout = "";let stderr = "";child.stdout.on("data", (chunk) => { stdout += chunk; });child.stderr.on("data", (chunk) => { stderr += chunk; });const timeout = setTimeout(() => child.kill("SIGKILL"), 15000);const status = await new Promise((resolve) => child.on("close", resolve));clearTimeout(timeout);server.close();// 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.if (!sentFrame) { throw new Error(`SETUP-FAILED: server never sent its frame, so this run tests nothing\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}`);}console.log("native-websocket-nowait-pump-integration-ok");test/integration/test-native-websocket-nowait-pump.shadded
#!/usr/bin/env bashset -euexec node test/integration/test-native-websocket-nowait-pump.mjs