From 607e0158ca8e176664075032711826f765900cf1 Mon Sep 17 00:00:00 2001 From: Matthew Fidler Date: Tue, 27 Oct 2026 09:24:51 -0500 Subject: [PATCH 1/2] Re-raise R callback errors by value, not via std::exception_ptr On CRAN's fedora-clang (libc++) errors from R callbacks in bobyqa(), newuoa() and steihaug() surfaced as 'c++ exception (unknown reason)' after std::rethrow_exception. Store the error (unwind token, interrupt or message) by value and throw a fresh exception instead (#3). --- DESCRIPTION | 2 +- NEWS.md | 8 +++++ src/minqa_r_interface.cpp | 14 ++++----- src/r_callback_error.h | 54 ++++++++++++++++++++++++++++++++++ src/steihaug_r_interface.cpp | 14 ++++----- tests/testthat/test-minqa.R | 13 ++++++++ tests/testthat/test-steihaug.R | 12 ++++++++ 7 files changed, 102 insertions(+), 15 deletions(-) create mode 100644 src/r_callback_error.h diff --git a/DESCRIPTION b/DESCRIPTION index 38be22a..15bb470 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: RcppTrust Type: Package Title: Thread-Safe C++ Trust Region Optimizers -Version: 0.2.0 +Version: 0.2.1 Authors@R: c( person(given = c("Charles", "J."), family = "Geyer", role = c("aut", "cph"), email = "geyer@umn.edu", comment = c(ORCID = "0000-0003-1471-1703")), diff --git a/NEWS.md b/NEWS.md index ad0b459..922c477 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,11 @@ +# RcppTrust 0.2.1 + +* Errors raised inside the R callbacks of `bobyqa()`, `newuoa()` and + `steihaug()` are again reported unchanged on clang/libc++ builds (CRAN's + fedora-clang check showed "c++ exception (unknown reason)"). The error + is now stored by value and re-thrown afresh instead of going through + `std::exception_ptr` (#3). + # RcppTrust 0.2.0 * The package license is now GPL-2, because the BOBYQA/NEWUOA ports and diff --git a/src/minqa_r_interface.cpp b/src/minqa_r_interface.cpp index bb0dfca..2e8f8b6 100644 --- a/src/minqa_r_interface.cpp +++ b/src/minqa_r_interface.cpp @@ -4,7 +4,7 @@ // *_solve_c() cores a C/C++ consumer calls -- only the objective callback // (which evaluates an R closure, so is main-thread only) differs. #include -#include +#include "r_callback_error.h" #include "minqa_types.h" using namespace Rcpp; @@ -12,7 +12,7 @@ namespace { struct MinqaRObjfun { SEXP fn; - std::exception_ptr error; + RCallbackError error; }; // Mirrors minqa's calfun(): Rf_asReal(fn(x)), evaluated in fn's @@ -31,7 +31,7 @@ int minqa_r_objfun(int n, const double *x, double *f, void *userdata) { *f = Rf_asReal(v); return 0; } catch (...) { - d->error = std::current_exception(); + d->error.capture(); return -1; } } @@ -53,9 +53,9 @@ minqa_options_t minqa_r_options(Environment ctrl) { // Shape of minqa's rval() list (before the R shim adds `msg`). List minqa_r_result(int n, minqa_result_t &res, MinqaRObjfun &d, const char *cls) { - if (d.error) { + if (d.error.failed()) { minqa_result_free(&res); - std::rethrow_exception(d.error); + d.error.rethrow(); } if (res.ierr == MINQA_ERR_NONFINITE_X) { minqa_result_free(&res); @@ -83,7 +83,7 @@ List minqa_r_result(int n, minqa_result_t &res, MinqaRObjfun &d, List minqa_bobyqa_r(NumericVector par, NumericVector lower, NumericVector upper, Environment ctrl, Function fn) { int n = par.size(); - MinqaRObjfun d{fn, nullptr}; + MinqaRObjfun d{fn, {}}; minqa_options_t opts = minqa_r_options(ctrl); minqa_result_t res; bobyqa_solve_c(n, par.begin(), lower.begin(), upper.begin(), minqa_r_objfun, @@ -94,7 +94,7 @@ List minqa_bobyqa_r(NumericVector par, NumericVector lower, NumericVector upper, // [[Rcpp::export]] List minqa_newuoa_r(NumericVector par, Environment ctrl, Function fn) { int n = par.size(); - MinqaRObjfun d{fn, nullptr}; + MinqaRObjfun d{fn, {}}; minqa_options_t opts = minqa_r_options(ctrl); minqa_result_t res; newuoa_solve_c(n, par.begin(), minqa_r_objfun, &d, &opts, &res); diff --git a/src/r_callback_error.h b/src/r_callback_error.h new file mode 100644 index 0000000..d447565 --- /dev/null +++ b/src/r_callback_error.h @@ -0,0 +1,54 @@ +#ifndef RCPPTRUST_R_CALLBACK_ERROR_H +#define RCPPTRUST_R_CALLBACK_ERROR_H + +// Captures an error raised inside an R callback so it can be re-raised +// once the thread-safe solver core has unwound. The error is stored by +// value and re-thrown as a fresh exception rather than through +// std::exception_ptr/std::rethrow_exception: with clang + libc++ (CRAN's +// fedora-clang) the rethrown object no longer matched Rcpp's catch +// clauses and surfaced as "c++ exception (unknown reason)". +#include +#include +#include + +class RCallbackError { + public: + // Call only from inside a catch block. + void capture() { + try { + throw; + } catch (Rcpp::internal::InterruptedException &) { + kind_ = INTERRUPT; + } catch (Rcpp::LongjumpException &e) { + // The unwind token resumes the original R condition unchanged. + kind_ = LONGJUMP; + token_ = e.token; + } catch (std::exception &e) { + kind_ = MESSAGE; + msg_ = e.what(); + } catch (...) { + kind_ = MESSAGE; + msg_ = "unknown C++ exception in R callback"; + } + } + + bool failed() const { return kind_ != NONE; } + + // Re-raise the captured error (no-op if nothing was captured). + void rethrow() const { + switch (kind_) { + case INTERRUPT: throw Rcpp::internal::InterruptedException(); + case LONGJUMP: throw Rcpp::LongjumpException(token_); + case MESSAGE: throw Rcpp::exception(msg_.c_str(), false); + case NONE: break; + } + } + + private: + enum Kind { NONE, INTERRUPT, LONGJUMP, MESSAGE }; + Kind kind_ = NONE; + Rcpp::RObject token_; // keeps the unwind token protected + std::string msg_; +}; + +#endif diff --git a/src/steihaug_r_interface.cpp b/src/steihaug_r_interface.cpp index 701c132..6d2ace4 100644 --- a/src/steihaug_r_interface.cpp +++ b/src/steihaug_r_interface.cpp @@ -3,8 +3,8 @@ // C/C++ consumer calls; only the callbacks (which evaluate R closures, so // are main-thread only) differ. #include -#include #include +#include "r_callback_error.h" #include "steihaug_types.h" using namespace Rcpp; @@ -12,7 +12,7 @@ namespace { struct SteihaugRObjfun { SEXP fn, gr, hess, hessvec; // hess/hessvec may be R_NilValue - std::exception_ptr error; + RCallbackError error; }; void steihaug_r_copy(SEXP v, int len, double *out, const char *what) { @@ -50,7 +50,7 @@ int steihaug_r_objfun(int n, const double *x, double *value, double *gradient, } return 0; } catch (...) { - d->error = std::current_exception(); + d->error.capture(); return -1; } } @@ -64,7 +64,7 @@ int steihaug_r_hessvec(int n, const double *x, const double *v, double *hv, steihaug_r_copy(hvf(xx, vv), n, hv, "hessvec"); return 0; } catch (...) { - d->error = std::current_exception(); + d->error.capture(); return -1; } } @@ -101,7 +101,7 @@ double ctrl_tol(List ctrl, const char *nm) { List steihaug_solve_r(NumericVector par, Function fn, Function gr, RObject hess, RObject hessvec, List control) { int n = par.size(); - SteihaugRObjfun d{fn, gr, hess, hessvec, nullptr}; + SteihaugRObjfun d{fn, gr, hess, hessvec, {}}; steihaug_options_t o = steihaug_options_default(); o.initial_radius = ctrl_dbl(control, "rinit"); @@ -132,9 +132,9 @@ List steihaug_solve_r(NumericVector par, Function fn, Function gr, hessvec.isNULL() ? nullptr : steihaug_r_hessvec, &d, &o, &res); - if (d.error) { + if (d.error.failed()) { steihaug_result_free(&res); - std::rethrow_exception(d.error); + d.error.rethrow(); } if (res.status == STEIHAUG_ERR_INVALID_OPTIONS) { steihaug_result_free(&res); diff --git a/tests/testthat/test-minqa.R b/tests/testthat/test-minqa.R index c9921e7..b78daa9 100644 --- a/tests/testthat/test-minqa.R +++ b/tests/testthat/test-minqa.R @@ -123,6 +123,19 @@ test_that("R errors inside fn propagate unchanged", { expect_error(bobyqa(c(1, 2), bad, lower = 0, upper = 4), "boom") expect_error(bobyqa(c(5, 2), fr, lower = 0, upper = 4), "Starting values violate bounds") + # The original condition object (and its class) reaches the caller. + raise <- function(x) { + stop(structure( + class = c("myErr", "error", "condition"), + list(message = "custom", call = NULL) + )) + } + expect_error(newuoa(c(1, 2), raise), "custom", class = "myErr") + expect_error( + bobyqa(c(1, 2), raise, lower = 0, upper = 4), + "custom", + class = "myErr" + ) }) test_that("extra arguments and names are passed to fn", { diff --git a/tests/testthat/test-steihaug.R b/tests/testthat/test-steihaug.R index 1a1234b..0402219 100644 --- a/tests/testthat/test-steihaug.R +++ b/tests/testthat/test-steihaug.R @@ -55,6 +55,18 @@ test_that("errors and malformed returns in R callbacks propagate", { "gradfail") expect_error(steihaug(c(-1.2, 1), fr, function(x) 1, hr), "length 2") expect_error(steihaug(c(-1.2, 1), fr, grr, function(x) diag(3)), "2 by 2") + raise <- function(...) { + stop(structure( + class = c("myErr", "error", "condition"), + list(message = "custom", call = NULL) + )) + } + expect_error(steihaug(c(-1.2, 1), raise, grr, hr), "custom", class = "myErr") + expect_error( + steihaug(c(-1.2, 1), fr, grr, hessvec = raise), + "custom", + class = "myErr" + ) }) test_that("infinite trial values are rejected, not accepted", { From 72e82f913394da43245f8a66fa5eca0d4867f9a1 Mon Sep 17 00:00:00 2001 From: Matthew Fidler Date: Tue, 27 Oct 2026 13:35:28 -0500 Subject: [PATCH 2/2] Exclude unreachable RCallbackError branches from coverage The INTERRUPT and NONE cases of rethrow() cannot be reached from tests (Rcpp delivers R interrupts as LongjumpException, and callers rethrow only after failed()), which dropped codecov/project on #4. --- src/r_callback_error.h | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/r_callback_error.h b/src/r_callback_error.h index d447565..847f65c 100644 --- a/src/r_callback_error.h +++ b/src/r_callback_error.h @@ -34,15 +34,18 @@ class RCallbackError { bool failed() const { return kind_ != NONE; } - // Re-raise the captured error (no-op if nothing was captured). + // Re-raise the captured error (no-op if nothing was captured). Under + // Rcpp's unwind protection an R-level interrupt arrives as a + // LongjumpException, and callers only rethrow after failed(), so the + // INTERRUPT and NONE cases are defensive and not reachable from tests. void rethrow() const { switch (kind_) { - case INTERRUPT: throw Rcpp::internal::InterruptedException(); + case INTERRUPT: throw Rcpp::internal::InterruptedException(); // # nocov case LONGJUMP: throw Rcpp::LongjumpException(token_); case MESSAGE: throw Rcpp::exception(msg_.c_str(), false); - case NONE: break; + case NONE: break; // # nocov start } - } + } // # nocov end private: enum Kind { NONE, INTERRUPT, LONGJUMP, MESSAGE };