From 0049eec3437e91e4a17a4182849abec8c5fa04e3 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Mon, 19 Oct 2026 17:30:17 -0500 Subject: [PATCH 1/5] Bound Matrix HTTP requests with finite deadlines --- R/http-timeouts.R | 46 +++++++++++ R/http.R | 2 +- R/media.R | 4 +- R/mx.api-package.R | 14 ++++ README.md | 20 +++++ inst/tinytest/test_http_stalls.R | 94 ++++++++++++++++++++++ inst/tinytest/test_http_timeouts.R | 122 +++++++++++++++++++++++++++++ man/mx.api-package.Rd | 15 ++++ 8 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 R/http-timeouts.R create mode 100644 inst/tinytest/test_http_stalls.R create mode 100644 inst/tinytest/test_http_timeouts.R diff --git a/R/http-timeouts.R b/R/http-timeouts.R new file mode 100644 index 0000000..633a8eb --- /dev/null +++ b/R/http-timeouts.R @@ -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) + ) +} diff --git a/R/http.R b/R/http.R index 91d8c8b..4870b54 100644 --- a/R/http.R +++ b/R/http.R @@ -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)) { diff --git a/R/media.R b/R/media.R index 5a2bc16..2d5754f 100644 --- a/R/media.R +++ b/R/media.R @@ -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, @@ -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) diff --git a/R/mx.api-package.R b/R/mx.api-package.R index c18836e..b531935 100644 --- a/R/mx.api-package.R +++ b/R/mx.api-package.R @@ -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" diff --git a/README.md b/README.md index 9bc46b6..3253608 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/inst/tinytest/test_http_stalls.R b/inst/tinytest/test_http_stalls.R new file mode 100644 index 0000000..5672bbe --- /dev/null +++ b/inst/tinytest/test_http_stalls.R @@ -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") +}) diff --git a/inst/tinytest/test_http_timeouts.R b/inst/tinytest/test_http_timeouts.R new file mode 100644 index 0000000..b5bc376 --- /dev/null +++ b/inst/tinytest/test_http_timeouts.R @@ -0,0 +1,122 @@ +library(tinytest) + +# Exercise the public entry points and capture the actual libcurl options. +# Transport fakes keep all requests local to this process. +local({ + old_options <- options(mx.api.connect_timeout = NULL, + mx.api.request_timeout = NULL, + mx.api.media_timeout = NULL) + on.exit(options(old_options), add = TRUE) + original_handle <- curl::new_handle + original_fetch <- curl::curl_fetch_memory + original_download <- curl::curl_download + on.exit({ + assignInNamespace("new_handle", original_handle, "curl") + assignInNamespace("curl_fetch_memory", original_fetch, "curl") + assignInNamespace("curl_download", original_download, "curl") + }, add = TRUE) + handles <- list() + requests <- 0L + fail <- FALSE + failure <- simpleError("fixture transport failure") + assignInNamespace("new_handle", function(...) { + handles[[length(handles) + 1L]] <<- list(...) + original_handle(...) + }, "curl") + assignInNamespace("curl_fetch_memory", function(url, handle) { + requests <<- requests + 1L + if (fail) stop(failure) + list(status_code = 200L, content = charToRaw(paste0( + '{"user_id":"@u:example","device_id":"DEV",', + '"access_token":"fixture","content_uri":"mxc://example/id"}' + ))) + }, "curl") + assignInNamespace("curl_download", function(url, destfile, handle) { + requests <<- requests + 1L + writeLines("fixture", destfile) + invisible(destfile) + }, "curl") + check_deadline <- function(total, connect = 10000L) { + h <- handles[[length(handles)]] + expect_identical(h$timeout_ms, as.integer(total)) + expect_identical(h$connecttimeout_ms, as.integer(connect)) + } + session <- mx.api::mx_session("https://example.invalid", "fixture", + "@u:example", "DEV") + src <- tempfile() + dest <- tempfile() + on.exit(unlink(c(src, dest)), add = TRUE) + writeLines("fixture", src) + + expect_identical(mx.api::mx_whoami(session)$user_id, "@u:example") + check_deadline(60000L) + mx.api::mx_login(session$server, "u", "fixture") + check_deadline(60000L) + mx.api::mx_sync(session, timeout = 30000L) + check_deadline(90000L) + mx.api::mx_keys_query(session, list("@u:example" = character())) + check_deadline(70000L) + mx.api::mx_keys_claim(session, list("@u:example" = list(DEV = "key"))) + check_deadline(70000L) + mx.api::mx_typing(session, "!room:example", timeout = 120000L) + check_deadline(60000L) # Typing lifetime is not a server-side wait. + expect_identical(mx.api::mx_upload(session, src), "mxc://example/id") + check_deadline(300000L) + mx.api::mx_download(session, "mxc://example/id", dest) + check_deadline(300000L) + expect_identical(readLines(dest), "fixture") + expect_identical(requests, 8L) + + options(mx.api.connect_timeout = 0.25, mx.api.request_timeout = 0.1, + mx.api.media_timeout = 0.5) + mx.api::mx_whoami(session) + check_deadline(100L, 100L) + mx.api::mx_sync(session, timeout = 500L) + check_deadline(600L, 250L) + mx.api::mx_upload(session, src) + check_deadline(500L, 250L) + mx.api::mx_download(session, "mxc://example/id", dest) + check_deadline(500L, 250L) + options(mx.api.request_timeout = 0.0001) + mx.api::mx_whoami(session) + check_deadline(1L, 1L) + + # Bad configuration must fail before dispatch, never turn off the limit. + before <- requests + bad_values <- list(0, -1, NA_real_, Inf, "1", TRUE, c(1, 2), + numeric(), .Machine$integer.max) + for (name in c("mx.api.connect_timeout", "mx.api.request_timeout", + "mx.api.media_timeout")) { + for (value in bad_values) { + options(mx.api.connect_timeout = 10, mx.api.request_timeout = 60, + mx.api.media_timeout = 300) + options(stats::setNames(list(value), name)) + if (name == "mx.api.media_timeout") { + expect_error(mx.api::mx_upload(session, src), "finite positive") + expect_error(mx.api::mx_download(session, "mxc://example/id", dest), + "finite positive") + } else { + expect_error(mx.api::mx_whoami(session), "finite positive") + } + } + } + options(mx.api.connect_timeout = 10, mx.api.request_timeout = 60, + mx.api.media_timeout = 300) + expect_error(mx.api::mx_sync(session, timeout = -1L), "server wait") + expect_error(mx.api::mx_sync(session, timeout = NA_integer_), "server wait") + expect_error(mx.api::mx_sync(session, timeout = c(1L, 2L)), "server wait") + expect_error(mx.api::mx_sync(session, timeout = .Machine$integer.max), + "deadline is too large") + expect_error(mx.api::mx_keys_query(session, list("@u:example" = character()), + timeout = -1L), "server wait") + expect_identical(requests, before) + + # A failed write can already have reached the server: do not retry it. + fail <- TRUE + err <- tryCatch(mx.api::mx_upload(session, src), error = identity) + expect_identical(err, failure) + expect_identical(requests, before + 1L) + err <- tryCatch(mx.api::mx_typing(session, "!room:example"), error = identity) + expect_identical(err, failure) + expect_identical(requests, before + 2L) +}) diff --git a/man/mx.api-package.Rd b/man/mx.api-package.Rd index f777040..3630ac2 100644 --- a/man/mx.api-package.Rd +++ b/man/mx.api-package.Rd @@ -5,6 +5,21 @@ \alias{mx.api-package} \title{\packageTitle{mx.api}} \description{\packageDescription{mx.api}} +\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. + +} \section{Package Content}{\packageIndices{mx.api}} \author{\packageAuthor{mx.api}} \section{Maintainer}{\packageMaintainer{mx.api}} From 8714d2a3778c499dfda2352fcd6c360bfaf48bb5 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Mon, 19 Oct 2026 17:30:21 -0500 Subject: [PATCH 2/5] Bump version to 0.3.1.2 --- DESCRIPTION | 4 ++-- NEWS.md | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 980d911..1838557 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -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.3.1.2 +Date: 2026-10-19 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai", diff --git a/NEWS.md b/NEWS.md index 22fbb53..4706f1e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,13 @@ +# 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()` From 64bcae40c656f8d028a226c5d8649a122639bca4 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Mon, 9 Nov 2026 23:01:05 -0600 Subject: [PATCH 3/5] Refresh cran-comments for 0.4.0 --- cran-comments.md | 59 +++++++++++++++++++++++++----------------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/cran-comments.md b/cran-comments.md index d98e9b7..d831bb5 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -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_`) - 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 @@ -41,6 +36,14 @@ 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 + +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`. -None on CRAN. +* mx.client, chat.api, and mx.crypto finish with Status: OK. +* corteza reports 1 failed assertion of 2563, a default-model check in + `test_cli_helpers.R`. The same assertion fails identically against + mx.api 0.3.1 on this machine, which carries a development build of + another dependency, so the failure does not come from this update. From 9af6968a55a99fb5bb28b3376bcffc87499003bf Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Mon, 9 Nov 2026 23:01:05 -0600 Subject: [PATCH 4/5] Bump version to 0.4.0 --- DESCRIPTION | 4 ++-- NEWS.md | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 1838557..025bc76 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,8 +1,8 @@ Package: mx.api Type: Package Title: Minimal Matrix Client-Server API -Version: 0.3.1.2 -Date: 2026-10-19 +Version: 0.4.0 +Date: 2026-11-09 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai", diff --git a/NEWS.md b/NEWS.md index 4706f1e..1e6ad9f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,8 @@ +# 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 From 3bdd11dae6feecbdd56298c7823cc24acd8f2444 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Mon, 9 Nov 2026 23:21:06 -0600 Subject: [PATCH 5/5] cran-comments: all four reverse dependencies pass --- cran-comments.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/cran-comments.md b/cran-comments.md index d831bb5..282bddb 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -40,10 +40,5 @@ runnable examples. 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`. - -* mx.client, chat.api, and mx.crypto finish with Status: OK. -* corteza reports 1 failed assertion of 2563, a default-model check in - `test_cli_helpers.R`. The same assertion fails identically against - mx.api 0.3.1 on this machine, which carries a development build of - another dependency, so the failure does not come from this update. +of each against this version with `R CMD check`; all four finish with +Status: OK.