Skip to content

Bidirectional (connect-only) socket connections via curl() - #449

Open
jeroen wants to merge 5 commits into
masterfrom
socket-connections
Open

Bidirectional (connect-only) socket connections via curl()#449
jeroen wants to merge 5 commits into
masterfrom
socket-connections

Conversation

@jeroen

@jeroen jeroen commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Alternative proposal for #448: instead of adding new entry points (curl_connect() / curl_send() / curl_recv()), expose CURLOPT_CONNECT_ONLY raw socket access through the existing connection interface.

Opening a curl connection in read-write mode now creates a bidirectional socket connection, analogous to base socketConnection() but with libcurl doing TCP, TLS, certificate verification, and proxy traversal:

con <- curl("imaps://imap.gmail.com", "r+")
writeLines("A1 CAPABILITY", con, sep = "\r\n")
readLines(con, n = 1)
#> [1] "* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST ..."
close(con)

How it works

The constraint behind #448 is that curl_easy_send()/curl_easy_recv() only work while the easy handle remains attached to its connection, which is why handles that went through the fetch functions cannot be used. But curl() connections already satisfy this: the handle stays in the connection's private multi handle until close(). So this slots into src/curl.c with no new C entry points, no new exports, and no changes to init.c/NAMESPACE:

  • rcurl_open() sets CURLOPT_CONNECT_ONLY when the open mode contains +. The existing multi open loop needs no changes: the perform completes right after the connect, and connect errors surface through the same curl_multi_info_read() path (honoring all handle options: timeouts, TLS, proxy).
  • con->read branches to curl_easy_recv() and the new con->write uses curl_easy_send(), each waiting on CURLINFO_ACTIVESOCKET with select(), in interruptible 500ms slices.
  • Standard connection semantics fall out for free: writeLines()/writeBin() to send, readLines()/readBin() to receive, open(con, "r+b", blocking = FALSE) for polling reads (returns available bytes immediately; isIncomplete() tells you the peer has not closed), and zero-byte read with isIncomplete() == FALSE at peer disconnect. The non-blocking mode covers the IMAP IDLE use case from Add curl_connect(), curl_send(), curl_recv(): raw socket access via CURLOPT_CONNECT_ONLY #448 without a timeout parameter.

Notes

  • For pingpong protocols (imaps://, smtps://, ...) libcurl consumes the server greeting (and runs its own capability exchange) during the connect phase, so the first read returns the response to your own first command. Documented in ?curl.
  • Heads-up for raw HTTP: on https:// URLs where ALPN negotiates HTTP/2, libcurl 8.x routes curl_easy_send() through the connection filter chain, so hand-written HTTP/1.1 does not go out verbatim. Set http_version = 2 (HTTP/1.1) on the handle for that use case. This equally affects Add curl_connect(), curl_send(), curl_recv(): raw socket access via CURLOPT_CONNECT_ONLY #448 as proposed. Plain TCP via telnet:// and pingpong protocols are unaffected.
  • A connect-only connection cannot be recycled for a regular transfer (libcurl documents the connection as one-shot); close() resets the option on the handle.

Testing

tests/testthat/test-socket.R (all skip_on_cran()):

  • plain TCP round trip and peer-disconnect EOF against a local serverSocket() (offline)
  • non-blocking reads, data arrival, and EOF against a local serverSocket() (offline)
  • IMAP CAPABILITY/LOGOUT dialogue over TLS against imap.gmail.com (also skip_if_offline())

Verified additionally with raw HTTP/1.1 over TLS against hb.cran.dev, and that existing connection tests (test-connection.R, test-blockopen.R, test-seek.R, test-nonblocking.R) still pass.

🤖 Generated with Claude Code

Opening a curl connection in "r+" or "r+b" mode now sets
CURLOPT_CONNECT_ONLY: libcurl only establishes the connection
(TCP, TLS handshake, certificate verification, proxy traversal)
without speaking the protocol, and read/write on the connection
map to curl_easy_recv()/curl_easy_send() on the socket. This
gives an interface similar to base socketConnection(), but with
TLS and proxy support, for speaking protocols or protocol
extensions that libcurl does not implement, such as IMAP IDLE.

Alternative to #448 without adding new API surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jeroen
jeroen force-pushed the socket-connections branch from 5146ed3 to 2f58ffb Compare September 2, 2026 12:46
jeroen and others added 2 commits September 2, 2026 14:01
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Like base socketConnection(), blocking reads on a connect-only
connection now wait at most getOption("timeout") seconds (default
60) and then return the available data, possibly zero bytes, with
isIncomplete() still TRUE. This prevents e.g. readLines(con) from
hanging indefinitely when the peer sends no more data. Sends that
cannot make progress within the timeout raise an error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jeroen

jeroen commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

Added in b0b0ecc: blocking reads/writes on socket connections now honor options(timeout) (default 60s), matching base socketConnection() semantics. Previously readLines(con) (without n) would block indefinitely waiting for more input, which could hang unattended CI jobs. Now it returns whatever was received once no more data arrives within the timeout, with isIncomplete() still TRUE so a timeout is distinguishable from the peer closing the connection (FALSE). Sends that cannot make progress within the timeout raise an error. Covered by a new offline test against a local serverSocket().

🤖 Generated with Claude Code

jeroen and others added 2 commits September 2, 2026 14:12
Requesting a socket connection with curl(url, "r+") now returns the
connection without opening it, so the user can choose how to open it:
a regular blocking open(con) for a request/response dialogue where
reads wait for data, or open(con, blocking = FALSE) to poll for data
without waiting, consistent with regular non-blocking curl connections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jeroen

jeroen commented Sep 2, 2026

Copy link
Copy Markdown
Owner Author

Design update: the options(timeout) behavior from before is reverted in favor of something simpler. Socket connections are now not opened automatically: curl(url, "r+") returns an unopened connection and the user calls open() themselves. A regular blocking open(con) gives dialogue-style reads that wait for data (interruptible), while open(con, blocking = FALSE) gives polling reads that return immediately with available data, consistent with how non-blocking curl connections already work:

con <- curl("imaps://imap.gmail.com", "r+")
open(con, blocking = FALSE)
writeLines("A1 CAPABILITY", con, sep = "\r\n")
while(isIncomplete(con)){
  line <- readLines(con, n = 1)
  if(length(line)){
    cat(line, "\n")
    if(grepl("^A1 ", line)) break
  }
}
writeLines("A2 LOGOUT", con, sep = "\r\n")
close(con)

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant