Commit5bdbe0c9Recorded3 Sep 2026Repositorysigil-websocket

Bound native WebSocket connection setup

Changed
 CHANGELOG.md                                               | 12 ++++++++++++
 package.sgl                                                |  2 +-
 src/sigil/websocket/connection.sgl                         | 45 ++++++++++++++++++++++++++++++++-------------
 test/integration/native-upgrade-timeout-client.sgl         | 19 +++++++++++++++++++
 test/integration/test-native-websocket-upgrade-timeout.mjs | 40 ++++++++++++++++++++++++++++++++++++++++
 test/integration/test-native-websocket-upgrade-timeout.sh  |  3 +++
 6 files changed, 107 insertions(+), 14 deletions(-)
Diff
CHANGELOG.mdmodified
@@ -7,6 +7,18 @@ All notable changes to this project will be documented in this file.
7
The format is based on [Keep a Changelog](https://keepachangelog.com/),
8
and this project adheres to [Semantic Versioning](https://semver.org/).
9
+10
## [0.10.9] - 2026-09-03
+11
+12
### Added
+13
+14
- Optional native WebSocket deadlines for TCP/TLS connect, TLS handshake, and
+15
the HTTP Upgrade response.
+16
+17
### Fixed
+18
+19
- Make a bounded HTTP Upgrade read nonblocking and scheduler-cooperative so a
+20
peer that accepts TLS and then goes silent cannot stall its caller forever.
+21
22
## [0.10.8] - 2026-09-01
23
24
### Fixed
package.sglmodified
@@ -8,7 +8,7 @@
8
9
(package
10
name: "sigil-websocket"
−11
version: "0.10.8"
+11
version: "0.10.9"
12
sigil: "0.20"
13
description: "WebSocket protocol library — client + server, with TLS"
14
url: "https://codeberg.org/sigil/sigil-websocket"
src/sigil/websocket/connection.sglmodified
@@ -8,6 +8,7 @@
8
(sigil string)
9
(sigil io)
10
(sigil struct)
+11
(only (sigil math) max)
12
(sigil async))
13
(cond-expand
14
(wasm
@@ -178,11 +179,13 @@
179
180
;;; Read HTTP response and validate handshake
181
;;; Returns #t on success, #f on failure
−181
(define (validate-handshake conn expected-accept)
+182
(define (validate-handshake conn expected-accept timeout-ms)
183
(let ((socket (ws-connection-socket conn)))
184
;; Read response (accumulate until we see \r\n\r\n). Keep the
185
;; accumulator binary: a server may coalesce its first WebSocket frame
186
;; with the 101 response, and that tail belongs to ws-receive.
+187
(let ((deadline (and timeout-ms
+188
(+ (current-milliseconds) timeout-ms))))
189
(let loop ((data (make-bytevector 0)))
190
(let ((chunk (conn-read socket 4096)))
191
(if (or (not chunk) (eof-object? chunk))
@@ -223,11 +226,18 @@
226
;; nonblocking, so an empty read means EAGAIN rather than
227
;; EOF. Yield until the fd is readable instead of spinning
228
;; and starving the server that will finish the upgrade.
−226
(begin
−227
(when (and (current-scheduler)
−228
(= (bytevector-length chunk-bytes) 0))
−229
(await-readable socket))
−230
(loop response)))))))))
+229
(if (and deadline
+230
(>= (current-milliseconds) deadline))
+231
#f
+232
(begin
+233
(when (= (bytevector-length chunk-bytes) 0)
+234
(if deadline
+235
(await-readable-any
+236
(list socket)
+237
timeout-ms: (max 1
+238
(- deadline (current-milliseconds))))
+239
(await-readable socket)))
+240
(loop response)))))))))))
241
242
;; ============================================================
243
;; Connection Helpers
@@ -305,8 +315,11 @@
315
;;; Connect to a WebSocket server
316
;;; url: WebSocket URL (ws:// or wss://)
317
;;; Returns ws-connection on success, #f on failure
−308
(define (ws-connect url)
−309
(: string? -> any?)
+318
(define (ws-connect url (keys: (connect-timeout-ms #f)
+319
(handshake-timeout-ms #f)
+320
(upgrade-timeout-ms #f)))
+321
(: string? (connect-timeout-ms: any?) (handshake-timeout-ms: any?)
+322
(upgrade-timeout-ms: any?) -> any?)
323
(let ((parsed (parse-ws-url url)))
324
(if (not parsed)
325
#f
@@ -329,7 +342,11 @@
342
(path (cadddr parsed))
343
;; Connect using TLS for wss://
344
(socket (if (string=? scheme "wss")
−332
(tls-connect host port)
+345
(if (or connect-timeout-ms handshake-timeout-ms)
+346
(tls-connect host port
+347
(or connect-timeout-ms 10000)
+348
(or handshake-timeout-ms 10000))
+349
(tls-connect host port))
350
(tcp-connect host port))))
351
(if (not socket)
352
#f
@@ -343,15 +360,17 @@
360
(key (generate-ws-key))
361
(expected-accept (calculate-accept-key key))
362
(request (build-handshake-request host path key)))
+363
;; Make the HTTP upgrade read cooperative before it can
+364
;; wait for a peer that accepted TLS and then went quiet.
+365
(when (or (current-scheduler) upgrade-timeout-ms)
+366
(conn-set-non-blocking! socket #t))
367
;; Send handshake request
368
(conn-write socket request)
369
;; Validate response
−349
(if (validate-handshake conn expected-accept)
+370
(if (validate-handshake conn expected-accept
+371
upgrade-timeout-ms)
372
(begin
373
(set-ws-connection-state! conn 'open)
−352
;; Set to non-blocking for async I/O when in scheduler
−353
(when (current-scheduler)
−354
(conn-set-non-blocking! socket #t))
374
conn)
375
(begin
376
(conn-close socket)
test/integration/native-upgrade-timeout-client.sgladded
@@ -0,0 +1,19 @@
+1
(import (sigil core)
+2
(sigil websocket))
+3
+4
(define port
+5
(or (and (getenv "SIGIL_WS_TEST_PORT")
+6
(string->number (getenv "SIGIL_WS_TEST_PORT")))
+7
(error "native upgrade-timeout client requires a port")))
+8
(define started (current-milliseconds))
+9
(define conn
+10
(ws-connect (string-append "ws://127.0.0.1:"
+11
(number->string port) "/")
+12
upgrade-timeout-ms: 250))
+13
(define elapsed (- (current-milliseconds) started))
+14
+15
(when conn (error "silent HTTP upgrade unexpectedly succeeded"))
+16
(unless (and (>= elapsed 150) (< elapsed 2000))
+17
(error "HTTP upgrade timeout was not bounded" elapsed))
+18
(display "native-websocket-upgrade-timeout-ok")
+19
(newline)
test/integration/test-native-websocket-upgrade-timeout.mjsadded
@@ -0,0 +1,40 @@
+1
import net from "node:net";
+2
import path from "node:path";
+3
import { fileURLToPath } from "node:url";
+4
import { spawn } from "node:child_process";
+5
+6
const here = path.dirname(fileURLToPath(import.meta.url));
+7
const repo = path.resolve(here, "../..");
+8
const server = net.createServer((socket) => {
+9
// Consume the request but deliberately never send the HTTP 101 response.
+10
socket.on("data", () => {});
+11
});
+12
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+13
const port = server.address().port;
+14
+15
const dependencyRoots = [
+16
"sigil-stdlib", "sigil-socket", "sigil-tls", "sigil-crypto",
+17
].map((name) => path.join(repo, ".sigil", "deps", name, "src"));
+18
const args = [
+19
"-L", path.join(repo, "build/dev/lib"),
+20
...dependencyRoots.flatMap((root) => ["-L", root]),
+21
path.join(here, "native-upgrade-timeout-client.sgl"),
+22
];
+23
const child = spawn("sigil", args, {
+24
cwd: "/tmp",
+25
env: { ...process.env, SIGIL_WS_TEST_PORT: String(port) },
+26
stdio: ["ignore", "pipe", "pipe"],
+27
});
+28
let stdout = "";
+29
let stderr = "";
+30
child.stdout.on("data", (chunk) => { stdout += chunk; });
+31
child.stderr.on("data", (chunk) => { stderr += chunk; });
+32
const watchdog = setTimeout(() => child.kill("SIGKILL"), 3000);
+33
const status = await new Promise((resolve) => child.on("close", resolve));
+34
clearTimeout(watchdog);
+35
server.close();
+36
+37
if (status !== 0 || !stdout.includes("native-websocket-upgrade-timeout-ok")) {
+38
throw new Error(`native timeout client failed (${status})\nstdout:\n${stdout}\nstderr:\n${stderr}`);
+39
}
+40
console.log("native-websocket-upgrade-timeout-integration-ok");
test/integration/test-native-websocket-upgrade-timeout.shadded
@@ -0,0 +1,3 @@
+1
#!/usr/bin/env bash
+2
set -eu
+3
exec node test/integration/test-native-websocket-upgrade-timeout.mjs