Commitd8caad28Recorded3 Sep 2026Repositorysigil-websocket

Bound plain WebSocket TCP connects

Changed
 CHANGELOG.md                                               |  8 ++++++++
 package.sgl                                                |  2 +-
 src/sigil/websocket/connection.sgl                         | 32 +++++++++++++++++++++++++++++++-
 test/integration/native-connect-timeout-client.sgl         | 17 +++++++++++++++++
 test/integration/test-native-websocket-connect-timeout.mjs | 29 +++++++++++++++++++++++++++++
 test/integration/test-native-websocket-connect-timeout.sh  |  3 +++
 6 files changed, 89 insertions(+), 2 deletions(-)
Diff
CHANGELOG.mdmodified
@@ -7,6 +7,14 @@ 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.10] - 2026-09-03
+11
+12
### Fixed
+13
+14
- Apply `connect-timeout-ms:` to plain `ws://` TCP setup as well as TLS
+15
connections, so a blackholed peer cannot indefinitely block retry or relay
+16
fallback.
+17
18
## [0.10.9] - 2026-09-03
19
20
### Added
package.sglmodified
@@ -8,7 +8,7 @@
8
9
(package
10
name: "sigil-websocket"
−11
version: "0.10.9"
+11
version: "0.10.10"
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
@@ -49,6 +49,35 @@
49
50
(begin
51
+52
;; Native sigil-socket exposes the bounded per-address connector as a
+53
;; primitive, while `tcp-connect` remains the compatibility surface. Keep
+54
;; WebSocket's public connect deadline honest for plain ws:// too: without
+55
;; this, a blackholed SYN never returns and neither the HTTP-upgrade timeout
+56
;; nor a caller's retry/fallback state machine can run.
+57
(cond-expand
+58
(wasm)
+59
(else
+60
(define-native (%tcp-connect-ip-timeout ip port timeout-ms)
+61
(: string? integer? integer? -> any?))))
+62
+63
(define (tcp-connect/bounded host port timeout-ms)
+64
(if (not timeout-ms)
+65
(tcp-connect host port)
+66
(let* ((resolved (resolve-hostname host))
+67
;; getaddrinfo is accumulated with cons by sigil-socket.
+68
(addresses (cond ((not resolved) '())
+69
((string? resolved) (list resolved))
+70
(else (reverse resolved))))
+71
(deadline (+ (current-milliseconds) timeout-ms)))
+72
(let loop ((xs addresses))
+73
(if (null? xs)
+74
#f
+75
(let ((remaining (- deadline (current-milliseconds))))
+76
(and (> remaining 0)
+77
(or (guard (e (#t #f))
+78
(%tcp-connect-ip-timeout (car xs) port remaining))
+79
(loop (cdr xs))))))))))
+80
81
;; ============================================================
82
;; WebSocket GUID (RFC 6455)
83
;; ============================================================
@@ -347,7 +376,8 @@
376
(or connect-timeout-ms 10000)
377
(or handshake-timeout-ms 10000))
378
(tls-connect host port))
−350
(tcp-connect host port))))
+379
(tcp-connect/bounded host port
+380
connect-timeout-ms))))
381
(if (not socket)
382
#f
383
(let* ((conn (ws-connection
test/integration/native-connect-timeout-client.sgladded
@@ -0,0 +1,17 @@
+1
(import (sigil core)
+2
(sigil websocket))
+3
+4
;; TEST-NET-3 is non-routable public documentation space. A host may reject it
+5
;; immediately; it must never hold ws-connect beyond the caller's deadline.
+6
(define started (current-milliseconds))
+7
(define conn
+8
(ws-connect "ws://203.0.113.1:32644/"
+9
connect-timeout-ms: 250
+10
upgrade-timeout-ms: 250))
+11
(define elapsed (- (current-milliseconds) started))
+12
+13
(when conn (error "documentation endpoint unexpectedly connected"))
+14
(unless (< elapsed 2000)
+15
(error "plain WebSocket TCP connect was not bounded" elapsed))
+16
(display "native-websocket-connect-timeout-ok")
+17
(newline)
test/integration/test-native-websocket-connect-timeout.mjsadded
@@ -0,0 +1,29 @@
+1
import path from "node:path";
+2
import { fileURLToPath } from "node:url";
+3
import { spawn } from "node:child_process";
+4
+5
const here = path.dirname(fileURLToPath(import.meta.url));
+6
const repo = path.resolve(here, "../..");
+7
const dependencyRoots = [
+8
"sigil-stdlib", "sigil-socket", "sigil-tls", "sigil-crypto",
+9
].map((name) => path.join(repo, ".sigil", "deps", name, "src"));
+10
const args = [
+11
"-L", path.join(repo, "build/dev/lib"),
+12
...dependencyRoots.flatMap((root) => ["-L", root]),
+13
path.join(here, "native-connect-timeout-client.sgl"),
+14
];
+15
const child = spawn("sigil", args, {
+16
cwd: "/tmp", env: process.env, stdio: ["ignore", "pipe", "pipe"],
+17
});
+18
let stdout = "";
+19
let stderr = "";
+20
child.stdout.on("data", (chunk) => { stdout += chunk; });
+21
child.stderr.on("data", (chunk) => { stderr += chunk; });
+22
const watchdog = setTimeout(() => child.kill("SIGKILL"), 3000);
+23
const status = await new Promise((resolve) => child.on("close", resolve));
+24
clearTimeout(watchdog);
+25
+26
if (status !== 0 || !stdout.includes("native-websocket-connect-timeout-ok")) {
+27
throw new Error(`native timeout client failed (${status})\nstdout:\n${stdout}\nstderr:\n${stderr}`);
+28
}
+29
console.log("native-websocket-connect-timeout-integration-ok");
test/integration/test-native-websocket-connect-timeout.shadded
@@ -0,0 +1,3 @@
+1
#!/usr/bin/env bash
+2
set -euo pipefail
+3
exec node test/integration/test-native-websocket-connect-timeout.mjs