Commit0a9a3d65Recorded1 Sep 2026Repositorysigil-websocket

Complete native WebSocket frame writes

Changed
 src/sigil/websocket/connection.sgl                | 46 ++++++++++++++++++++++++++--------------------
 test/integration/native-binary-client.sgl         |  6 ++++++
 test/integration/test-native-websocket-binary.mjs | 48 ++++++++++++++++++++++++++++++++++++++++--------
 3 files changed, 72 insertions(+), 28 deletions(-)
Diff
src/sigil/websocket/connection.sglmodified
@@ -233,7 +233,10 @@
233
;; Connection Helpers
234
;; ============================================================
235
−236
;;; Write to socket (handles both TCP and TLS)
+236
;;; Write one complete WebSocket frame. Plain scheduler-owned sockets are
+237
;;; nonblocking after the handshake, so a successful socket-write may cover
+238
;;; only a prefix. Preserve the already-encoded (and already-masked) frame,
+239
;;; wait for writability, and resume at its byte offset.
240
(define (conn-write sock data)
241
(cond-expand
242
(wasm
@@ -241,7 +244,22 @@
244
(else
245
(if (tls-connection? sock)
246
(tls-write sock data)
−244
(socket-write sock data)))))
+247
(let ((len (if (string? data) (string-length data)
+248
(bytevector-length data))))
+249
(let loop ((offset 0))
+250
(if (>= offset len)
+251
#t
+252
;; Offset arguments are present in the native socket ABI;
+253
;; apply tolerates the older stdlib arity annotation.
+254
(let ((sent (apply socket-write
+255
(list sock data offset len))))
+256
(cond
+257
((and (integer? sent) (> sent 0))
+258
(loop (+ offset sent)))
+259
((and (current-scheduler) (not (socket-closed? sock)))
+260
(await-writable sock)
+261
(loop offset))
+262
(else #f))))))))))
263
264
;;; Read from socket (handles both TCP and TLS).
265
;;;
@@ -358,10 +376,7 @@
376
(conn-write (ws-connection-socket conn) message))
377
(else
378
(let ((frame (encode-text-frame message)))
−361
(conn-write (ws-connection-socket conn)
−362
(if (string? frame)
−363
frame
−364
(utf8->string frame))))))))
+379
(conn-write (ws-connection-socket conn) frame))))))
380
381
;;; Send a binary message
382
(define (ws-send-binary conn data)
@@ -372,10 +387,7 @@
387
(conn-write (ws-connection-socket conn) data))
388
(else
389
(let ((frame (encode-binary-frame data)))
−375
(conn-write (ws-connection-socket conn)
−376
(if (string? frame)
−377
frame
−378
(utf8->string frame))))))))
+390
(conn-write (ws-connection-socket conn) frame))))))
391
392
;;; Send a ping frame
393
(define (ws-ping conn . payload)
@@ -388,10 +400,7 @@
400
(let ((frame (if (null? payload)
401
(encode-ping-frame)
402
(encode-ping-frame (car payload)))))
−391
(conn-write (ws-connection-socket conn)
−392
(if (string? frame)
−393
frame
−394
(utf8->string frame))))))))
+403
(conn-write (ws-connection-socket conn) frame))))))
404
405
;;; Close the WebSocket connection
406
(define (ws-close conn)
@@ -404,8 +413,7 @@
413
(else
414
;; Send close frame
415
(let ((close-frame (encode-close-frame 1000))) ; 1000 = normal closure
−407
(conn-write (ws-connection-socket conn)
−408
(utf8->string close-frame)))))
+416
(conn-write (ws-connection-socket conn) close-frame))))
417
;; Close socket
418
(conn-close (ws-connection-socket conn))
419
(set-ws-connection-state! conn 'closed)))
@@ -487,16 +495,14 @@
495
(set-ws-connection-state! conn 'closed)
496
;; Send close response
497
(let ((close-frame (encode-close-frame)))
−490
(conn-write (ws-connection-socket conn)
−491
(utf8->string close-frame)))
+498
(conn-write (ws-connection-socket conn) close-frame))
499
(conn-close (ws-connection-socket conn))
500
'closed)
501
502
;; Ping frame - respond with pong, need more data
503
((= opcode opcode-ping)
504
(let ((pong-frame (encode-pong-frame payload)))
−498
(conn-write (ws-connection-socket conn)
−499
(utf8->string pong-frame)))
+505
(conn-write (ws-connection-socket conn) pong-frame))
506
'need-more)
507
508
;; Pong frame - ignore, need more data
test/integration/native-binary-client.sglmodified
@@ -11,6 +11,8 @@
11
(loop (+ i 1))))))
12
(error "native WebSocket binary payload was not byte-exact")))
13
+14
(define large (make-bytevector (+ (* 2 1024 1024) 37) #x5a))
+15
16
(define port
17
(or (and (getenv "SIGIL_WS_TEST_PORT")
18
(string->number (getenv "SIGIL_WS_TEST_PORT")))
@@ -22,6 +24,10 @@
24
;; round trip. The server verifies the masked client frame byte-for-byte.
25
(ws-send-binary conn (bytevector #x00 #x7f #x80 #xff #x41))
26
+27
;; Larger than a typical nonblocking socket send buffer: the server must see
+28
;; one complete masked message, never a prefix or separately remasked suffix.
+29
(ws-send-binary conn large)
+30
31
;; The server coalesces its first binary frame with the HTTP 101 response. It
32
;; must remain in the connection buffer after handshake validation.
33
(define first (ws-receive conn))
test/integration/test-native-websocket-binary.mjsmodified
@@ -16,12 +16,29 @@ function decodeClientFrame(buffer) {
16
if (buffer.length < 6) return null;
17
const opcode = buffer[0] & 0x0f;
18
const masked = (buffer[1] & 0x80) !== 0;
−19
const length = buffer[1] & 0x7f;
−20
if (!masked || length >= 126 || buffer.length < 6 + length) return null;
−21
const mask = buffer.subarray(2, 6);
+19
const shortLength = buffer[1] & 0x7f;
+20
let length;
+21
let headerLength;
+22
if (shortLength < 126) {
+23
length = shortLength;
+24
headerLength = 2;
+25
} else if (shortLength === 126) {
+26
if (buffer.length < 8) return null;
+27
length = buffer.readUInt16BE(2);
+28
headerLength = 4;
+29
} else {
+30
if (buffer.length < 14) return null;
+31
const wide = buffer.readBigUInt64BE(2);
+32
if (wide > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("fixture frame is too large");
+33
length = Number(wide);
+34
headerLength = 10;
+35
}
+36
if (!masked || buffer.length < headerLength + 4 + length) return null;
+37
const mask = buffer.subarray(headerLength, headerLength + 4);
38
const payload = Buffer.alloc(length);
−23
for (let i = 0; i < length; i += 1) payload[i] = buffer[6 + i] ^ mask[i % 4];
−24
return { opcode, payload, consumed: 6 + length };
+39
const start = headerLength + 4;
+40
for (let i = 0; i < length; i += 1) payload[i] = buffer[start + i] ^ mask[i % 4];
+41
return { opcode, payload, consumed: start + length };
42
}
43
44
const server = net.createServer();
@@ -56,7 +73,11 @@ server.on("connection", (socket) => {
73
while ((decoded = decodeClientFrame(buffer))) {
74
receivedClientFrames.push(decoded);
75
buffer = buffer.subarray(decoded.consumed);
−59
if (decoded.opcode === 2) {
+76
// Hold the ordinary response until BOTH client binary frames are whole.
+77
// The client cannot close after its small-frame acknowledgement while the
+78
// kernel still has the large frame queued, making completeness causal.
+79
if (decoded.opcode === 2 &&
+80
receivedClientFrames.filter((frame) => frame.opcode === 2).length === 2) {
81
setTimeout(() => socket.write(serverFrame(Buffer.from([0x10, 0xfe, 0x20, 0x80]))), 10);
82
}
83
}
@@ -70,7 +91,7 @@ const dependencyRoots = [
91
"sigil-stdlib", "sigil-socket", "sigil-tls", "sigil-crypto",
92
].map((name) => path.join(repo, ".sigil", "deps", name, "src"));
93
const args = [
−73
"-L", path.join(repo, "src"),
+94
"-L", path.join(repo, "build/dev/lib"),
95
...dependencyRoots.flatMap((root) => ["-L", root]),
96
path.join(here, "native-binary-client.sgl"),
97
];
@@ -88,7 +109,9 @@ const timeout = setTimeout(() => child.kill("SIGKILL"), 5000);
109
110
const status = await new Promise((resolve) => child.on("close", resolve));
111
clearTimeout(timeout);
−91
const received = receivedClientFrames.find((frame) => frame.opcode === 2);
+112
const binary = receivedClientFrames.filter((frame) => frame.opcode === 2);
+113
const received = binary[0];
+114
const receivedLarge = binary[1];
115
server.close();
116
117
if (!received) throw new Error(`native client sent no decodable binary frame\nstdout:\n${stdout}\nstderr:\n${stderr}`);
@@ -96,6 +119,15 @@ if (received.opcode !== 2) throw new Error(`expected binary opcode, got ${receiv
119
if (!received.payload.equals(Buffer.from([0x00, 0x7f, 0x80, 0xff, 0x41]))) {
120
throw new Error(`native client binary write was not byte-exact: ${received.payload.toString("hex")}`);
121
}
+122
const largeLength = (2 * 1024 * 1024) + 37;
+123
if (!receivedLarge || receivedLarge.payload.length !== largeLength) {
+124
throw new Error(`native client large binary frame was truncated: ${receivedLarge?.payload.length ?? 0}/${largeLength}\nstatus: ${status}\nstdout:\n${stdout}\nstderr:\n${stderr}`);
+125
}
+126
for (let i = 0; i < receivedLarge.payload.length; i += 1) {
+127
if (receivedLarge.payload[i] !== 0x5a) {
+128
throw new Error(`native client large binary frame differs at byte ${i}`);
+129
}
+130
}
131
if (status !== 0 || !stdout.includes("native-websocket-binary-ok")) {
132
throw new Error(`native client failed (${status})\nstdout:\n${stdout}\nstderr:\n${stderr}`);
133
}