I did this
Our app (the Ladybird browser engine) uses libcurl as a raw WebSocket client: CURLOPT_CONNECT_ONLY=2 with CURLWS_RAW_MODE. So, libcurl handles the transport and we do our own framing. (We need raw mode rather than the framed curl_ws_* API because, as a browser engine, our app has to negotiate permessage-deflate and can’t live with the 64K framed-frame cap. See related discussion in #16106 and #16389.)
If the server sends a frame the moment the handshake finishes — before our client code sends anything — curl_easy_recv() often never returns it. It just gives CURLE_AGAIN indefinitely — even though the server sent the frame and the connection is healthy. A recv(MSG_PEEK) on CURLINFO_ACTIVESOCKET returns EAGAIN at that point. So, the bytes aren’t in the OS receive buffer — libcurl consumed them while finishing the upgrade, and then doesn’t surface them in raw mode.
It’s a coalescing race: It only happens when the server frame arrives back-to-back with the 101 (zero server-side delay). If the server waits even 1ms, then it’s always delivered. And the exact same connection in framed mode delivers it every time. So, libcurl does have the bytes.
Minimal repro below. libcurl 8.20.0, macOS arm64, 40 connects each, server delay 0:
- raw mode (
curl_easy_recv): 25/40 lost
- framed mode (
curl_ws_recv): 0/40 lost
Build and run:
cc client.c -lcurl -o client
python3 server.py 8099 0 &
./client ws://127.0.0.1:8099/ # run it a handful of times
server.py
#!/usr/bin/env python3
import socket, sys, hashlib, base64, struct, threading, time
GUID = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
def handshake(conn):
data = b""
while b"\r\n\r\n" not in data:
chunk = conn.recv(4096)
if not chunk:
return False
data += chunk
key = None
for line in data.split(b"\r\n"):
if line.lower().startswith(b"sec-websocket-key:"):
key = line.split(b":", 1)[1].strip()
if not key:
return False
accept = base64.b64encode(hashlib.sha1(key + GUID).digest())
conn.sendall(
b"HTTP/1.1 101 Switching Protocols\r\n"
b"Upgrade: websocket\r\nConnection: Upgrade\r\n"
b"Sec-WebSocket-Accept: " + accept + b"\r\n\r\n")
return True
def text_frame(payload):
b = payload if isinstance(payload, bytes) else payload.encode()
assert len(b) < 126
return b"\x81" + struct.pack("!B", len(b)) + b
def read_client_frame(conn):
hdr = conn.recv(2)
if len(hdr) < 2:
return None
ln = hdr[1] & 0x7f
masked = hdr[1] & 0x80
if ln == 126:
ln = struct.unpack("!H", conn.recv(2))[0]
elif ln == 127:
ln = struct.unpack("!Q", conn.recv(8))[0]
mask = conn.recv(4) if masked else b"\x00\x00\x00\x00"
data = b""
while len(data) < ln:
c = conn.recv(ln - len(data))
if not c:
return None
data += c
return bytes(data[i] ^ mask[i % 4] for i in range(len(data)))
def serve(conn, delay_ms):
try:
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
if not handshake(conn):
return
if delay_ms > 0:
time.sleep(delay_ms / 1000.0)
conn.sendall(text_frame("hello"))
while True:
p = read_client_frame(conn)
if p is None:
break
conn.sendall(text_frame(b"echo:" + p))
except Exception:
pass
finally:
conn.close()
def main():
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8099
delay_ms = int(sys.argv[2]) if len(sys.argv) > 2 else 0
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", port))
s.listen(64)
sys.stderr.write(f"ws_server on 127.0.0.1:{port} delay={delay_ms}ms\n")
sys.stderr.flush()
while True:
conn, _ = s.accept()
threading.Thread(target=serve, args=(conn, delay_ms), daemon=True).start()
if __name__ == "__main__":
main()
client.c
#include <curl/curl.h>
#include <curl/websockets.h>
#include <errno.h>
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
int main(int argc, char** argv)
{
const char* url = argc > 1 ? argv[1] : "ws://127.0.0.1:8099/";
curl_global_init(CURL_GLOBAL_DEFAULT);
CURL* easy = curl_easy_init();
curl_easy_setopt(easy, CURLOPT_URL, url);
curl_easy_setopt(easy, CURLOPT_WS_OPTIONS, (long)CURLWS_RAW_MODE);
curl_easy_setopt(easy, CURLOPT_CONNECT_ONLY, 2L);
curl_easy_setopt(easy, CURLOPT_CONNECTTIMEOUT, 10L);
CURLcode rc = curl_easy_perform(easy); // connects + does the WS handshake, then returns
if (rc != CURLE_OK) {
fprintf(stderr, "perform: %s\n", curl_easy_strerror(rc));
printf("CONNECT-FAIL\n");
return 2;
}
curl_socket_t sock = CURL_SOCKET_BAD;
curl_easy_getinfo(easy, CURLINFO_ACTIVESOCKET, &sock);
char buf[65536];
size_t nread = 0;
CURLcode r = curl_easy_recv(easy, buf, sizeof(buf), &nread);
if (r == CURLE_OK && nread > 0) {
printf("PASS-IMMEDIATE (%zu)\n", nread);
return 0;
}
for (int i = 0; i < 60; ++i) {
struct pollfd pfd = { .fd = sock, .events = POLLIN, .revents = 0 };
poll(&pfd, 1, 100);
nread = 0;
r = curl_easy_recv(easy, buf, sizeof(buf), &nread);
if (r == CURLE_OK && nread > 0) {
printf("PASS-LATE (%zu, iter %d)\n", nread, i);
return 0;
}
}
{
char pk[64]; ssize_t pn = recv(sock, pk, sizeof(pk), MSG_PEEK | MSG_DONTWAIT);
if (pn > 0) printf("LOST (but %zd bytes ARE on the OS socket)\n", pn);
else printf("LOST (OS socket recv(MSG_PEEK)=%zd errno=%d EAGAIN=%d)\n", pn, errno, EAGAIN);
}
return 1;
}
I expected the following
In CONNECT_ONLY raw mode, curl_easy_recv() should return the post-handshake bytes libcurl already buffered while completing the upgrade — the same bytes curl_ws_recv() returns in framed mode. Instead, the bytes are stranded inside libcurl, and unreachable in raw mode — unless they happen to be read in the same pass that finishes the handshake.
curl/libcurl version
curl 8.20.0 (aarch64-apple-darwin25.4.0) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.2.12 brotli/1.2.0 zstd/1.5.7 AppleIDN libssh2/1.11.1 nghttp2/1.69.0 ngtcp2/1.23.0 nghttp3/1.16.0 mit-krb5/1.7-prerelease OpenLDAP/2.4.28/Apple
Release-Date: 2026-04-29
Protocols: dict file ftp ftps gopher gophers http https imap imaps ipfs ipns ldap ldaps mqtt mqtts pop3 pop3s rtsp scp sftp smtp smtps telnet tftp ws wss
operating system
Darwin 25.5.0 (macOS), arm64 (Apple Silicon)
I did this
Our app (the Ladybird browser engine) uses libcurl as a raw WebSocket client:
CURLOPT_CONNECT_ONLY=2withCURLWS_RAW_MODE. So, libcurl handles the transport and we do our own framing. (We need raw mode rather than the framedcurl_ws_*API because, as a browser engine, our app has to negotiatepermessage-deflateand can’t live with the 64K framed-frame cap. See related discussion in #16106 and #16389.)If the server sends a frame the moment the handshake finishes — before our client code sends anything —
curl_easy_recv()often never returns it. It just givesCURLE_AGAINindefinitely — even though the server sent the frame and the connection is healthy. Arecv(MSG_PEEK)onCURLINFO_ACTIVESOCKETreturnsEAGAINat that point. So, the bytes aren’t in the OS receive buffer — libcurl consumed them while finishing the upgrade, and then doesn’t surface them in raw mode.It’s a coalescing race: It only happens when the server frame arrives back-to-back with the 101 (zero server-side delay). If the server waits even 1ms, then it’s always delivered. And the exact same connection in framed mode delivers it every time. So, libcurl does have the bytes.
Minimal repro below. libcurl 8.20.0, macOS arm64, 40 connects each, server delay 0:
curl_easy_recv): 25/40 lostcurl_ws_recv): 0/40 lostBuild and run:
server.py
client.c
I expected the following
In
CONNECT_ONLYraw mode,curl_easy_recv()should return the post-handshake bytes libcurl already buffered while completing the upgrade — the same bytescurl_ws_recv()returns in framed mode. Instead, the bytes are stranded inside libcurl, and unreachable in raw mode — unless they happen to be read in the same pass that finishes the handshake.curl/libcurl version
operating system
Darwin 25.5.0 (macOS), arm64 (Apple Silicon)