Commit48ace9acRecorded4 Aug 2026Repositorysigil-tls

tls: let tls-write yield on a non-blocking connection

Message

On a non-blocking connection tls-write retried WANT_WRITE with a bare continue: a hot loop that never returned and never yielded. So a peer that accepted the connection and then stopped reading could pin a CPU indefinitely, and no caller could bound the write from Sigil at all. That is why sigil-http writes its request while still blocking and only switches to non-blocking for the response read.

Non-blocking now returns the bytes actually committed, possibly 0, so a caller can poll against a deadline. 0 means retry the same slice, which is what mbedTLS requires after WANT_WRITE; a positive return means those bytes are committed. Blocking connections keep the original retry-until-complete behaviour.

tls-write also gains optional start/end byte offsets, mirroring socket-write, so a write loop resending the tail of a large body stays O(n) instead of O(n^2).

The non-blocking flag is recorded only after the fd has actually changed mode, so it cannot claim a mode the socket is not in.

No in-suite test here: exercising it needs a real TLS peer that stops reading, which this suite cannot stand up in-process. It is covered end to end in sigil-http against a TLS server that never drains its receive queue.

Changed
 native/tls.c      | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
 src/sigil/tls.sgl | 18 +++++++++++++++++-
 2 files changed, 74 insertions(+), 10 deletions(-)
Diff
native/tls.cmodified
@@ -90,6 +90,11 @@ typedef struct {
90
mbedtls_net_context server_fd;
91
mbedtls_x509_crt cacert;
92
int closed;
+93
/* Tracks tls-set-non-blocking!. tls-write consults it: in blocking mode
+94
* it keeps retrying WANT_WRITE (the original behaviour), in non-blocking
+95
* mode it must hand control back instead of spinning on a socket that
+96
* will not accept bytes. */
+97
int nonblocking;
98
} TlsConnectionData;
99
100
/* Symbol used as type tag for TLS connection foreign objects */
@@ -766,14 +771,28 @@ static Value native_tls_read_bytevector(SigilVM *vm, int argc, Value *args)
771
}
772
773
/*
−769
* tls-write connection data -> integer | #f
−770
* Write data to TLS connection.
−771
* Returns number of bytes written, or #f on error.
+774
* tls-write connection data [start [end]] -> integer | #f
+775
*
+776
* Write data to a TLS connection. Returns the number of bytes written, or
+777
* #f on error.
+778
*
+779
* BLOCKING connection (the default): retries WANT_READ/WANT_WRITE until the
+780
* whole buffer is written, exactly as before.
+781
*
+782
* NON-BLOCKING connection (after tls-set-non-blocking!): returns the number
+783
* of bytes actually committed, which may be 0, instead of spinning. The old
+784
* behaviour on a non-blocking socket was a `continue` on WANT_WRITE, i.e. a
+785
* busy loop that never returned and never yielded — unbounded AND hot. A
+786
* caller can now poll against a deadline. A 0 return means "nothing was
+787
* accepted, retry the SAME slice", which is what mbedTLS requires after
+788
* WANT_WRITE.
+789
*
+790
* The optional [start] and [end] byte offsets let a write loop resend the
+791
* tail of a buffer without copying it each time, keeping a large body O(n)
+792
* instead of O(n^2). This mirrors socket-write.
793
*/
794
static Value native_tls_write(SigilVM *vm, int argc, Value *args)
795
{
−775
(void)argc;
−776
796
if (!is_tls_connection(args[0])) {
797
sigil__vm_error(vm, SIGIL_ERR_TYPE, "tls-write: expected tls-connection");
798
return SIGIL_UNDEFINED;
@@ -801,6 +820,24 @@ static Value native_tls_write(SigilVM *vm, int argc, Value *args)
820
return SIGIL_UNDEFINED;
821
}
822
+823
/* Optional [start] and [end] byte offsets, clamped into the buffer. */
+824
size_t start = 0;
+825
size_t end = len;
+826
if (argc >= 3 && sigil_is_fixnum(args[2])) {
+827
long s = (long)sigil_as_fixnum(args[2]);
+828
if (s < 0) s = 0;
+829
start = (size_t)s > len ? len : (size_t)s;
+830
}
+831
if (argc >= 4 && sigil_is_fixnum(args[3])) {
+832
long e = (long)sigil_as_fixnum(args[3]);
+833
if (e < 0) e = 0;
+834
end = (size_t)e > len ? len : (size_t)e;
+835
}
+836
if (end < start) end = start;
+837
+838
data += start;
+839
len = end - start;
+840
841
int ret;
842
size_t total_written = 0;
843
@@ -810,7 +847,12 @@ static Value native_tls_write(SigilVM *vm, int argc, Value *args)
847
len - total_written);
848
849
if (ret == MBEDTLS_ERR_SSL_WANT_READ || ret == MBEDTLS_ERR_SSL_WANT_WRITE) {
−813
continue; /* Retry */
+850
if (conn->nonblocking) {
+851
/* Hand control back so the caller can enforce a deadline
+852
* (and let other tasks run) rather than spinning here. */
+853
break;
+854
}
+855
continue; /* Blocking: retry, as before. */
856
}
857
858
if (ret < 0) {
@@ -891,7 +933,9 @@ static Value native_tls_set_non_blocking(SigilVM *vm, int argc, Value *args)
933
934
#ifdef _WIN32
935
u_long mode = enable ? 1 : 0;
−894
return sigil_bool(ioctlsocket(fd, FIONBIO, &mode) == 0);
+936
if (ioctlsocket(fd, FIONBIO, &mode) != 0) return SIGIL_FALSE;
+937
conn->nonblocking = enable;
+938
return SIGIL_TRUE;
939
#else
940
int flags = fcntl(fd, F_GETFL, 0);
941
if (flags == -1) return SIGIL_FALSE;
@@ -902,7 +946,11 @@ static Value native_tls_set_non_blocking(SigilVM *vm, int argc, Value *args)
946
flags &= ~O_NONBLOCK;
947
}
948
−905
return sigil_bool(fcntl(fd, F_SETFL, flags) != -1);
+949
if (fcntl(fd, F_SETFL, flags) == -1) return SIGIL_FALSE;
+950
/* Recorded only after the fd actually changed mode, so the flag can
+951
* never claim a mode the socket is not in. */
+952
conn->nonblocking = enable;
+953
return SIGIL_TRUE;
954
#endif
955
}
956
@@ -1163,7 +1211,7 @@ void sigil__init_sigil_tls_module(SigilVM *vm)
1211
REGISTER_AND_EXPORT("tls-read-bytevector", native_tls_read_bytevector,
1212
SIGIL_ARITY_RANGE(1, 2), "Read raw bytes from TLS connection");
1213
REGISTER_AND_EXPORT("tls-write", native_tls_write,
−1166
SIGIL_ARITY_EXACT(2), "Write to TLS connection");
+1214
SIGIL_ARITY_RANGE(2, 4), "Write to TLS connection");
1215
REGISTER_AND_EXPORT("tls-close", native_tls_close,
1216
SIGIL_ARITY_EXACT(1), "Close TLS connection");
1217
REGISTER_AND_EXPORT("tls-closed?", native_tls_closedp,
src/sigil/tls.sglmodified
@@ -111,10 +111,26 @@
111
;;; Accepts a string or bytevector. Returns the number of bytes
112
;;; written, or #f on error.
113
;;;
+114
;;; On a BLOCKING connection (the default) this writes the whole buffer
+115
;;; before returning, exactly as before.
+116
;;;
+117
;;; On a NON-BLOCKING connection (see `tls-set-non-blocking!`) it returns
+118
;;; the number of bytes actually committed, which may be `0`, so a caller
+119
;;; can poll against a deadline. Previously a non-blocking connection made
+120
;;; this spin in a hot loop that never returned, so a peer that stopped
+121
;;; reading could pin a CPU forever. A `0` return means retry the SAME
+122
;;; slice; a positive return means those bytes are committed and the next
+123
;;; call should start after them.
+124
;;;
+125
;;; The optional `start` and `end` byte offsets let a write loop resend
+126
;;; the tail of a buffer without copying it, keeping a large body O(n)
+127
;;; rather than O(n^2). This mirrors `socket-write`.
+128
;;;
129
;;; ```scheme
130
;;; (tls-write conn "GET / HTTP/1.1\r\n\r\n") ; => integer | #f
+131
;;; (tls-write conn body 4096) ; write from byte 4096 on
132
;;; ```
−117
(define-native (tls-write connection data)
+133
(define-native (tls-write connection data . start-end)
134
(: any? (any-of string? bytevector?) -> (any-of integer? boolean?)))
135
136
;;; Close a TLS connection.