Skip to content

WebSocket raw mode: curl_easy_recv loses a frame sent right after the handshake #22107

Description

@sideshowbarker

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions