diff --git a/NEWS.md b/NEWS.md index d1f7a599fb..7d8c7019bb 100644 --- a/NEWS.md +++ b/NEWS.md @@ -10,6 +10,8 @@ * Added a new `ExtendedTask` abstraction, for long-running asynchronous tasks that you don't want to block the rest of the app, or even the rest of the session. Designed to be used with new `bslib::input_task_button()` and `bslib::bind_task_button()` functions that help give user feedback and prevent extra button clicks. (#3958) +* Added a `shiny.error.unhandled` option that can be set to a function that will be called when an unhandled error occurs in a Shiny app. Note that this handler doesn't stop the error or prevent the session from closing, but it can be used to log the error or to clean up session-specific resources. (thanks @JohnCoene, #3989) + ## Bug fixes * Notifications are now constrained to the width of the viewport for window widths smaller the default notification panel size. (#3949) diff --git a/R/mock-session.R b/R/mock-session.R index fd4227d295..76d33ccf69 100644 --- a/R/mock-session.R +++ b/R/mock-session.R @@ -563,6 +563,7 @@ MockShinySession <- R6Class( #' @description Called by observers when a reactive expression errors. #' @param e An error object. unhandledError = function(e) { + shinyUserErrorUnhandled(e) self$close() }, #' @description Freeze a value until the flush cycle completes. diff --git a/R/shiny-options.R b/R/shiny-options.R index 6a7feedd9a..b40831f55d 100644 --- a/R/shiny-options.R +++ b/R/shiny-options.R @@ -81,6 +81,12 @@ getShinyOption <- function(name, default = NULL) { #' \item{shiny.error (defaults to `NULL`)}{This can be a function which is called when an error #' occurs. For example, `options(shiny.error=recover)` will result a #' the debugger prompt when an error occurs.} +#' \item{shiny.error.unhandled (defaults to `NULL`)}{A function that will be +#' called when an unhandled error that will stop the app session occurs. This +#' function should take the error condition object as its first argument. +#' Note that this function will not stop the error or prevent the session +#' from ending, but it will provide you with an opportunity to log the error +#' or clean up resources before the session is closed.} #' \item{shiny.fullstacktrace (defaults to `FALSE`)}{Controls whether "pretty" (`FALSE`) or full #' stack traces (`TRUE`) are dumped to the console when errors occur during Shiny app execution. #' Pretty stack traces attempt to only show user-supplied code, but this pruning can't always diff --git a/R/shiny.R b/R/shiny.R index 4be5381d95..53cf6d93ff 100644 --- a/R/shiny.R +++ b/R/shiny.R @@ -1044,6 +1044,8 @@ ShinySession <- R6Class( return(private$inputReceivedCallbacks$register(callback)) }, unhandledError = function(e) { + "Call the user's unhandled error handler and then close the session." + shinyUserErrorUnhandled(e) self$close() }, close = function() { diff --git a/R/utils.R b/R/utils.R index 78c6daec37..e2f0a07714 100644 --- a/R/utils.R +++ b/R/utils.R @@ -493,6 +493,30 @@ shinyCallingHandlers <- function(expr) { ) } +shinyUserErrorUnhandled <- function(error, handler = NULL) { + if (is.null(handler)) { + handler <- getShinyOption( + "shiny.error.unhandled", + getOption("shiny.error.unhandled", NULL) + ) + } + + if (is.null(handler)) return() + + if (!is.function(handler) || length(formals(handler)) == 0) { + warning( + "`shiny.error.unhandled` must be a function ", + "that takes an error object as its first argument", + immediate. = TRUE + ) + return() + } + + tryCatch( + shinyCallingHandlers(handler(error)), + error = printError + ) +} #' Register a function with the debugger (if one is active). #' diff --git a/man/shinyOptions.Rd b/man/shinyOptions.Rd index 2e43e69cd9..3b2386aafc 100644 --- a/man/shinyOptions.Rd +++ b/man/shinyOptions.Rd @@ -60,6 +60,12 @@ deprecated functions in Shiny will be printed. See \item{shiny.error (defaults to \code{NULL})}{This can be a function which is called when an error occurs. For example, \code{options(shiny.error=recover)} will result a the debugger prompt when an error occurs.} +\item{shiny.error.unhandled (defaults to \code{NULL})}{A function that will be +called when an unhandled error that will stop the app session occurs. This +function should take the error condition object as its first argument. +Note that this function will not stop the error or prevent the session +from ending, but it will provide you with an opportunity to log the error +or clean up resources before the session is closed.} \item{shiny.fullstacktrace (defaults to \code{FALSE})}{Controls whether "pretty" (\code{FALSE}) or full stack traces (\code{TRUE}) are dumped to the console when errors occur during Shiny app execution. Pretty stack traces attempt to only show user-supplied code, but this pruning can't always diff --git a/tests/testthat/test-test-server.R b/tests/testthat/test-test-server.R index 7a227316ac..d3dc8e7ca6 100644 --- a/tests/testthat/test-test-server.R +++ b/tests/testthat/test-test-server.R @@ -507,6 +507,53 @@ test_that("session ended handlers work", { }) }) +test_that("shiny.error.unhandled handles unhandled errors", { + caught <- NULL + op <- options(shiny.error.unhandled = function(error) { + caught <<- error + stop("bad user error handler") + }) + on.exit(options(op)) + + server <- function(input, output, session) { + observe({ + req(input$boom > 1) # This signals an error that shiny handles + stop("unhandled error") # This error is *not* and brings down the app + }) + } + + testServer(server, { + session$setInputs(boom = 1) + # validation errors *are* handled and don't trigger the unhandled error + expect_null(caught) + expect_false(session$isEnded()) + expect_false(session$isClosed()) + + # All errors are caught, even the error from the unhandled error handler + # And these errors are converted to warnings + expect_no_error( + expect_warning( + expect_warning( + # Setting input$boom = 2 throws two errors that become warnings: + # 1. The unhandled error in the observe + # 2. The error thrown by the user error handler + capture.output( + session$setInputs(boom = 2), + type = "message" + ), + "unhandled error" + ), + "bad user error handler" + ) + ) + + expect_s3_class(caught, "error") + expect_equal(conditionMessage(caught), "unhandled error") + expect_true(session$isEnded()) + expect_true(session$isClosed()) + }) +}) + test_that("session flush handlers work", { server <- function(input, output, session) { rv <- reactiveValues(x = 0, flushCounter = 0, flushedCounter = 0,