Skip to content

turn exceptions during module initialization into r errors - #1526

Merged
kevinushey merged 3 commits into
masterfrom
bugfix/module-boot-exceptions
Nov 3, 2026
Merged

kevinushey merged 3 commits into
masterfrom
bugfix/module-boot-exceptions

Conversation

@kevinushey

Copy link
Copy Markdown
Contributor

Fixes #1525.

The boot function generated by RCPP_MODULE is an extern "C" entry point called from R via .Call(), but had no BEGIN_RCPP/END_RCPP. Any C++ exception thrown by the module body, such as Rcpp's own std::range_error("no such class") from .derives<T>() with a misspelled parent, escaped into R and std::terminate() aborted the session. It also skipped ::setCurrentScope( 0 ), leaving the global module scope pointing at the half-initialized module. See #1525 for the reprex. This is the boot-function counterpart of #1495 / #1496.

Changes (inst/include/Rcpp/Module.h):

  • RCPP_MODULE: the boot function body is wrapped in BEGIN_RCPP/END_RCPP, so exceptions become R errors like in every other .Call entry point.
  • New Rcpp::internal::ModuleScope, a small RAII guard that sets the current module scope and clears it in its destructor, so the scope is reset on the error path as well as on success.

The change is confined to the macro, so it applies to a package once it is rebuilt; nothing in Rcpp.so changes. One caller-visible difference: code that calls the boot function directly from C++ instead of through .Call(), such as RInside's LOAD_RCPP_MODULE, now gets an R error rather than a C++ exception when the module body throws. Previously a throwing module body was fatal whenever it was loaded from R, so this only matters for that direct-call path.

Tests:

  • inst/tinytest/cpp/ModuleInitError.cpp: a module whose body throws (derives() with a parent that was never exposed).
  • inst/tinytest/test_module.R: sourceCpp() of that file raises the no such class R error, and afterwards the current module scope is cleared (checked through a small exported helper added to cpp/Module.cpp).

Verification:

  • The reprex from an exception while a module initializes aborts the R session #1525 aborts (SIGABRT, exit code 134) against current master and prints result: no such class with exit code 0 against this branch.
  • Against master the new test aborts the test process the same way; on this branch test_module.R (47/47), test_modref.R, test_expose_class.R and test_module_client_package.R pass with RunAllRcppTests=yes RunVerboseRcppTests=yes (R 4.6.1, macOS).
  • Compiling the test modules with -Wall -Wextra -Wpedantic -Wconversion -Wshadow -Wunreachable-code reports no warnings from the new macro or guard.

Checklist

  • Code compiles correctly
  • R CMD check still passes all tests (module test files run locally as above)
  • Preferably, new tests were added which fail without the change (without the fix, the new test terminates the test process)
  • Document the changes by file in ChangeLog

@eddelbuettel

Copy link
Copy Markdown
Member

The LLM I had look at this at comments about possible nesting of scopes. What do you think? PLAN.md below.


Plan: Implement PR #1526 — turn exceptions during module initialization into R errors

Summary of findings

  • PR: turn exceptions during module initialization into r errors #1526 — "turn exceptions during module initialization into r errors" (author: kevinushey). Fixes an exception while a module initializes aborts the R session #1525.
  • Problem: The boot function generated by RCPP_MODULE is an extern "C" SEXP entry point called from R via .Call() but has no BEGIN_RCPP/END_RCPP. Any C++ exception from the module body (e.g. std::range_error("no such class") thrown by .derives<T>() with a misspelled parent) escapes into R, hits std::terminate(), and aborts the session (SIGABRT, exit 134). It also skips setCurrentScope(0), leaving the global module scope pointing at a half-initialized module.
  • Fix: Wrap the boot function in BEGIN_RCPP/END_RCPP and introduce an RAII Rcpp::internal::ModuleScope guard that sets/clears the current module scope on both success and error paths. Macro-only change -> takes effect when a package is rebuilt; nothing in Rcpp.so changes.

Local verification of the codebase

  • Local tree: /home/edd/tmp/rcpp/Rcpp/ — Rcpp 1.1.2.6, master, unmodified (not a git repo). The PR changes are NOT present.
  • RCPP_MODULE macro currently at inst/include/Rcpp/Module.h:615-625 (raw extern "C" boot, no exception guard).
  • getCurrentScope() / setCurrentScope() declared at inst/include/Rcpp/routines.h:75-76, defined at routines.h:274-284 (runtime callables).
  • BEGIN_RCPP at inst/include/Rcpp/macros/macros.h:49; END_RCPP at macros.h:99 (expands to VOID_END_RCPP return R_NilValue;). macros.h is pulled in via Rcpp/r/headers.h, and Module.h is included from Rcpp.h, so the macros are available — compile check still required.
  • END_RCPP returns R_NilValue on the error path and falls through on success, so return Rcpp::XPtr<Rcpp::Module>(...) (implicit SEXP conversion) is fine.
  • inst/tinytest/cpp/ModuleInitError.cpp does not exist locally.
  • inst/tinytest/test_module.R requires RunAllRcppTests=yes and the Rcpp modules capability; it does sourceCpp("cpp/Module.cpp") at line 23.
  • Toolchain available: R 4.6.2 (/usr/lib/R).

Assessment

Correct and well-scoped. The fix is the boot-function analog of the earlier .External entry-point fix (#1496) and is at the right layer (the macro). The RAII guard is the key improvement over just adding END_RCPP: it also resets scope on the exception path.

Concerns

  1. XPtr<Module> return inside BEGIN_RCPP/END_RCPP — verify it compiles (main risk).
  2. Guard semantics: the PR hard-codes setCurrentScope(0). Recommendation: save and restore the previous scope to avoid clobbering an enclosing scope (relevant to LOAD_RCPP_MODULE / nested loads). Superset of PR behavior, no regression.
  3. Behavioral change (author-flagged): LOAD_RCPP_MODULE (RInside) calls the boot function directly from C++; a throwing module now raises an R error instead of propagating a catchable C++ exception. Was fatal when loaded from R anyway -> acceptable, but warrants an RInside/consumer sanity check.
  4. Failed-init module state: the static Rcpp::Module persists in a bad state after a failed _init(). This fix makes the error clean, not the module state; pre-existing and out of scope.
  5. RCPP_NO_MODULES / include-order: confirm ModuleScope (references Module*) compiles in all include configurations.

Implementation steps

1. inst/include/Rcpp/Module.h

Add the RAII guard before the RCPP_MODULE_BOOT macro (after line 611), using save/restore:

namespace Rcpp {
namespace internal {
    class ModuleScope {
    public:
        explicit ModuleScope( Module* module ) :
            previous( ::getCurrentScope() ){ ::setCurrentScope( module ) ; }
        ~ModuleScope(){ ::setCurrentScope( previous ) ; }
        ModuleScope( const ModuleScope& ) = delete ;
        ModuleScope& operator=( const ModuleScope& ) = delete ;
    private:
        Module* previous ;
    } ;
}
}

Replace RCPP_MODULE(name) (lines 615-625):

#define RCPP_MODULE(name)                                               \
    void _rcpp_module_##name##_init() ;                                 \
    static Rcpp::Module _rcpp_module_##name( # name ) ;                 \
    extern "C" SEXP _rcpp_module_boot_##name(){                         \
        BEGIN_RCPP                                                      \
        Rcpp::internal::ModuleScope _rcpp_module_scope( & _rcpp_module_##name ) ; \
        _rcpp_module_##name##_init( ) ;                                 \
        return Rcpp::XPtr<Rcpp::Module>( & _rcpp_module_##name , false ) ; \
        END_RCPP                                                        \
    }                                                                   \
    void _rcpp_module_##name##_init()

2. inst/tinytest/cpp/ModuleInitError.cpp (new)

GPL header + a class and RCPP_MODULE(moduleInitError) whose body calls .derives<ModuleInitError>("NoSuchParent") (throws std::range_error("no such class")).

3. inst/tinytest/cpp/Module.cpp

Append after line 245:

// [[Rcpp::export]]
bool Module_scope_is_cleared() {
    return getCurrentScope() == NULL;
}

4. inst/tinytest/test_module.R

Insert after line 109:

## an exception thrown while a module initializes raises an R error rather
## than terminating the R session, and clears the module scope (#1525)
expect_error( Rcpp::sourceCpp("cpp/ModuleInitError.cpp"), "no such class" )
expect_true( Module_scope_is_cleared() )

5. ChangeLog

Prepend a 2026-10-29 entry describing the macro wrap, the ModuleScope guard, and the new test files.

Verification

  1. R CMD INSTALL /home/edd/tmp/rcpp/Rcpp — macro + guard compile, headers resolve.
  2. Reprex an exception while a module initializes aborts the R session #1525 (typo'd .derives): assert exit code 0 and message no such class (was SIGABRT/134).
  3. RunAllRcppTests=yes RunVerboseRcppTests=yes -> test_module.R, test_modref.R, test_expose_class.R, test_module_client_package.R.
  4. Compile the new test module with -Wall -Wextra -Wpedantic — no new warnings.
  5. (Optional) LOAD_RCPP_MODULE-style direct-call sanity check.

Open question

  • Confirm the save/restore guard (recommended) vs. the PR's literal hard-coded setCurrentScope(0).

A module loaded from within another module body (say, via a namespace
load whose .onLoad calls loadModule()) would reset the outer scope to
null; ModuleScope now saves and restores the previous scope.
@kevinushey

Copy link
Copy Markdown
Contributor Author

Good catch, thanks. Nesting can only happen if a module body itself triggers another module's boot (say, user code in the body loads a namespace whose .onLoad calls loadModule(..., TRUE), or RInside code calls LOAD_RCPP_MODULE from inside a body). Nothing in Rcpp does that, and the hard-coded reset to 0 goes back to the original 2010 modules code, so master behaves the same way: after the inner boot returns, the outer body runs with a null scope and the next class_<> dereferences it. So this PR neither introduced nor fixed that.

That said, save/restore is one pointer, header-only (no Rcpp.so change), and makes the guard correct in the nested case too, so ModuleScope now restores the previous scope in ca4cc5c. The four module test files still pass.

@eddelbuettel

Copy link
Copy Markdown
Member

I think this is ready to (squash) merge. Do you want to do the honours, @kevinushey ?

@kevinushey
kevinushey merged commit b0bfb82 into master Nov 3, 2026
28 checks passed
@eddelbuettel
eddelbuettel deleted the bugfix/module-boot-exceptions branch November 3, 2026 21:14
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.

an exception while a module initializes aborts the R session

2 participants