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 datatest/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
}