Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
Package: mx.api
Type: Package
Title: Minimal Matrix Client-Server API
Version: 0.3.1.1
Date: 2026-10-02
Version: 0.4.0
Date: 2026-11-09
Authors@R: c(
person("Troy", "Hernandez", role = c("aut", "cre"),
email = "troy@cornball.ai",
Expand Down
15 changes: 15 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
# mx.api 0.4.0

* Minor release collecting the MatrixRTC endpoints and the HTTP request
deadlines from 0.3.1.1 and 0.3.1.2 below. No code changes since 0.3.1.2.

# mx.api 0.3.1.2

* Bound HTTP requests so a stalled connection cannot hold a polling loop
indefinitely. Defaults allow 10 seconds to connect, 60 seconds for ordinary
requests, and 300 seconds for media transfers. Sync and federated key
requests add their server-side wait to the total deadline.
* Add finite positive timeout options, in seconds: `mx.api.connect_timeout`,
`mx.api.request_timeout`, and `mx.api.media_timeout`. Timeout errors propagate
to the caller; the transport does not automatically replay requests.

# mx.api 0.3.1.1

* New: the endpoints a MatrixRTC client needs. `mx_openid_token()`
Expand Down
46 changes: 46 additions & 0 deletions R/http-timeouts.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# All HTTP entry points use finite deadlines. A server-side wait (sync or
# federated key lookup) is additional to the ordinary network allowance.
mx_timeout_option <- function(name, default) {
value <- getOption(name, default)
if (!is.numeric(value) || length(value) != 1L || is.na(value) ||
!is.finite(value) || value <= 0 ||
value > .Machine$integer.max / 1000) {
stop(name, " must be a finite positive number of seconds, at most ",
.Machine$integer.max / 1000, ".", call. = FALSE)
}
value
}

mx_server_wait <- function(path, body, query) {
wait <- 0
if (identical(path, "/_matrix/client/v3/sync")) {
wait <- query$timeout %||% 0
} else if (path %in% c("/_matrix/client/v3/keys/query",
"/_matrix/client/v3/keys/claim")) {
wait <- body$timeout %||% 0
}
if (!is.numeric(wait) || length(wait) != 1L || is.na(wait) ||
!is.finite(wait) || wait < 0) {
stop("Matrix server wait must be a finite non-negative number of milliseconds.",
call. = FALSE)
}
wait
}

mx_http_handle <- function(wait = 0, media = FALSE) {
connect <- mx_timeout_option("mx.api.connect_timeout", 10)
if (media) {
budget <- mx_timeout_option("mx.api.media_timeout", 300)
} else {
budget <- mx_timeout_option("mx.api.request_timeout", 60)
}

total_ms <- ceiling(budget * 1000 + wait)
if (!is.finite(total_ms) || total_ms > .Machine$integer.max) {
stop("Matrix request deadline is too large.", call. = FALSE)
}
curl::new_handle(
connecttimeout_ms = as.integer(min(ceiling(connect * 1000), total_ms)),
timeout_ms = as.integer(total_ms)
)
}
2 changes: 1 addition & 1 deletion R/http.R
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ mx_http <- function(base_url, method, path, body = NULL, query = NULL,
url <- paste0(url, "?", paste(pairs, collapse = "&"))
}

h <- curl::new_handle()
h <- mx_http_handle(wait = mx_server_wait(path, body, query))
curl::handle_setopt(h, customrequest = method)
headers <- c(Accept = "application/json")
if (!is.null(token)) {
Expand Down
4 changes: 2 additions & 2 deletions R/media.R
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ mx_upload <- function(session, path, content_type = NULL, filename = NULL) {
con <- file(path, "rb")
on.exit(close(con), add = TRUE)

h <- curl::new_handle()
h <- mx_http_handle(media = TRUE)
curl::handle_setopt(
h,
upload = TRUE,
Expand Down Expand Up @@ -98,7 +98,7 @@ mx_download <- function(session, mxc_url, dest) {
utils::URLencode(media_id, reserved = TRUE)
)

h <- curl::new_handle()
h <- mx_http_handle(media = TRUE)
curl::handle_setheaders(h, Authorization = paste("Bearer", session$token))
curl::curl_download(url, dest, handle = h)
invisible(dest)
Expand Down
14 changes: 14 additions & 0 deletions R/mx.api-package.R
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@
#' End-to-end encryption is out of scope; use unencrypted rooms or a
#' separate crypto package.
#'
#' @section Network deadlines:
#' Every HTTP request has a connection timeout and a total deadline, including
#' login, sync, key exchange and media transfers. Options
#' \code{mx.api.connect_timeout}, \code{mx.api.request_timeout} and
#' \code{mx.api.media_timeout} set these limits in seconds; defaults are
#' 10, 60 and 300 respectively. Values must be finite and positive.
#' Sync and federated key lookups add their requested server-side wait to the
#' request deadline, so a 30-second sync has a 90-second total deadline.
#' Connection setup remains limited to 10 seconds by default.
#'
#' Timeouts propagate as curl errors for the caller's retry loop. Requests are
#' not automatically replayed: a timed-out write may already have succeeded
#' on the server. A failed download may leave a partial destination file.
#'
#' @name mx.api-package
#' @aliases mx.api
"_PACKAGE"
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,26 @@ str(batch$rooms$join)
mx_logout(s)
```

## Network deadlines

Requests allow 10 seconds to connect and 60 seconds in total. Sync and
federated key lookups add their requested server-side wait to the total;
a 30-second sync therefore has a 90-second deadline. Uploads and downloads
allow 300 seconds in total. These limits also cover stalled connections.

Set finite positive limits in seconds when a deployment needs different ones:

```r
options(mx.api.connect_timeout = 10,
mx.api.request_timeout = 60,
mx.api.media_timeout = 300)
```

Timeouts return a curl error so a bot's polling loop can retry. The transport
does not replay requests automatically: a timed-out write may already have
succeeded on the server. Failed downloads may leave a partial destination file.
These limits apply to Matrix traffic, not model generation.

## Create a private room with invites

```r
Expand Down
54 changes: 26 additions & 28 deletions cran-comments.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,29 @@

## Test environments

* local Ubuntu 24.04, R 4.6.0
* local Windows 10: R 4.6.0 and R-devel (4.7.0 pre-release), both OK;
R 4.5.1 with 1 environmental NOTE ("unable to verify current time")
* local Ubuntu 24.04, R 4.6.1, `R CMD check --as-cran --run-donttest`
* GitHub Actions (ubuntu-latest, macos-latest) via r-ci

## What's new in 0.3.0

Additive only; no changes to the existing 0.2.0 surface.

* Generic room-event and state plumbing: `mx_send_event()`,
`mx_set_state()`, `mx_get_state()` (what an external
end-to-end-encryption layer needs to send `m.room.encrypted` events
and read `m.room.encryption` state).
* Media messages: `mx_send_media()` uploads a file and posts the
referencing message, deriving the msgtype from the MIME type;
`mx_send_file()` / `mx_send_image()` / `mx_send_audio()` /
`mx_send_video()` fix it explicitly. `mx_guess_mime()` is exported;
`mx_media_config()` reports the server's upload cap. `mx_upload()`
now streams from disk instead of reading files into memory.
* Bot lifecycle endpoints: `mx_room_invite()`, `mx_redact()`,
`mx_typing()`, `mx_profile()`, `mx_set_displayname()`,
`mx_set_avatar_url()`.
* Account data: `mx_get_account_data()` / `mx_set_account_data()`.
* Devices: `mx_devices()`, `mx_delete_device()` (user-interactive auth
payloads pass through verbatim).
* HTTP failures now signal classed conditions (`mx_error_<ERRCODE>`)
carrying the Matrix errcode, HTTP status, and parsed body; message
text is unchanged from previous releases.
## What's new in 0.4.0

This is an update to 0.3.1. It is additive only: no exported function
was removed or renamed, and no exported signature changed.

* New endpoints for 'MatrixRTC' clients: `mx_openid_token()`,
`mx_rtc_transports()`, `mx_rtc_livekit_token()`, `mx_room_state()`,
and `mx_well_known_client()`.
* HTTP requests now carry finite deadlines, so a stalled connection
cannot hold a polling loop indefinitely. Three options set them, in
seconds: `mx.api.connect_timeout` (default 10),
`mx.api.request_timeout` (default 60), and `mx.api.media_timeout`
(default 300). Long-poll requests add their server-side wait to the
deadline.

## Notes on tests

The timeout tests listen on the loopback interface and connect to that
listener from the test process. They contact no external host and need
no credentials. They run on Unix-alikes and skip elsewhere.

## Notes on examples

Expand All @@ -41,6 +36,9 @@ cannot execute under `R CMD check --as-cran`. Pure functions
(`mx_session()`, `mx_canonical_json()`, `mx_guess_mime()`) have
runnable examples.

## Downstream dependencies
## Reverse dependencies

None on CRAN.
Four on CRAN: mx.client 0.2.1 and chat.api 0.1.0 import this package;
mx.crypto 0.2.2 and corteza 0.7.1 suggest it. I checked the CRAN source
of each against this version with `R CMD check`; all four finish with
Status: OK.
94 changes: 94 additions & 0 deletions inst/tinytest/test_http_stalls.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
library(tinytest)

# Real libcurl requests against a delayed loopback server. The fixture answers
# after two seconds even without the fix, so a regression cannot hang the suite.
if (.Platform$OS.type != "unix" || !exists("serverSocket", baseenv())) {
exit_file("Local socket fixture requires Unix and serverSocket")
}

local({
old_options <- options(mx.api.connect_timeout = 0.1,
mx.api.request_timeout = 0.2,
mx.api.media_timeout = 0.2)
on.exit(options(old_options), add = TRUE)
# A proxy must not redirect synthetic requests out of the loopback fixture.
proxy_names <- c("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY",
"ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy")
old_env <- Sys.getenv(proxy_names, unset = NA_character_)
on.exit({
Sys.unsetenv(names(old_env)[is.na(old_env)])
present <- old_env[!is.na(old_env)]
if (length(present)) {
do.call(Sys.setenv, as.list(present))
}
}, add = TRUE)
Sys.unsetenv(proxy_names)
src <- tempfile()
dest <- tempfile()
on.exit(unlink(c(src, dest)), add = TRUE)
writeLines("fixture", src)

probe <- function(request, delay = 2, tls = FALSE) {
listener <- NULL
for (port in sample(20000:50000, 20L)) {
listener <- tryCatch(serverSocket(port), error = function(e) NULL)
if (!is.null(listener)) break
}
if (is.null(listener)) stop("Could not bind loopback fixture")
on.exit(close(listener), add = TRUE)
accepted <- tempfile()
on.exit(unlink(accepted), add = TRUE)
child <- parallel::mcparallel({
con <- socketAccept(listener, blocking = TRUE, open = "a+b",
timeout = 5)
writeLines("accepted", accepted)
Sys.sleep(delay)
body <- '{"user_id":"@u:example","next_batch":"next"}'
response <- paste0("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n",
"Content-Length: ", nchar(body, type = "bytes"),
"\r\nConnection: close\r\n\r\n", body)
writeBin(charToRaw(response), con)
close(con)
TRUE
}, silent = TRUE)
on.exit({
# Terminate only this fixture's child, before it can write to a
# timed-out socket. Reaping it also prevents PID reuse races.
tools::pskill(child$pid)
suppressWarnings(parallel::mccollect(child))
}, add = TRUE)
scheme <- if (tls) "https" else "http"
session <- mx.api::mx_session(paste0(scheme, "://127.0.0.1:", port),
"fixture", "@u:example", "DEV")
start <- proc.time()[["elapsed"]]
value <- tryCatch(request(session), error = identity)
elapsed <- proc.time()[["elapsed"]] - start
expect_true(file.exists(accepted), info = "Fixture accepted the connection")
list(value = value, elapsed = elapsed)
}
timed_out <- function(result) {
expect_inherits(result$value, "curl_error_operation_timedout")
expect_true(result$elapsed < 1.5,
info = paste("Deadline elapsed seconds:", result$elapsed))
}

# Healthy control and a delayed but legitimate long poll both complete.
ok <- probe(mx.api::mx_whoami, delay = 0)
expect_identical(ok$value$user_id, "@u:example")
ok <- probe(function(s) mx.api::mx_sync(s, timeout = 1000L), delay = 0.4)
expect_identical(ok$value$next_batch, "next")

timed_out(probe(mx.api::mx_whoami))
timed_out(probe(function(s) mx.api::mx_sync(s, timeout = 200L)))
timed_out(probe(function(s) mx.api::mx_upload(s, src)))
timed_out(probe(function(s) mx.api::mx_download(s, "mxc://example/id", dest)))

# TCP accepts immediately; a silent TLS handshake must hit the connection
# deadline even when the overall request allowance is much longer.
options(mx.api.request_timeout = 10)
timed_out(probe(mx.api::mx_whoami, tls = TRUE))

# A failed request must not poison the next poll in the same process.
ok <- probe(function(s) mx.api::mx_sync(s, since = "old"), delay = 0)
expect_identical(ok$value$next_batch, "next")
})
Loading
Loading