Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Register an rlang_error method for knitr::sew() to handle backtraces in error messages #1215

Merged
merged 3 commits into from
May 21, 2021
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
18 changes: 18 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# rlang (development version)

* A `knitr::sew()` method is registered for `rlang_error`. This makes
it possible to consult `last_error()` (the call must occur in a
different chunk than the error) and to set
`rlang_backtrace_on_error` global options in knitr to display a
backtrace on error.

If you show rlang backtraces in a knitted document, also set this in
a hidden chunk to trim the knitr context from the backtraces:

```
options(
rlang_trace_top_env = environment()
)
```

This change replaces an ad hoc mechanism that caused bugs in corner
cases (#1205).

* Internal errors now include a winch backtrace if installed. The user
is invited to install it if not installed.

Expand Down
67 changes: 57 additions & 10 deletions R/cnd-abort.R
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,26 @@
#' When set to quiet, the message is not displayed and the condition
#' is not signalled.
#'
#' @details
#'
#' - An `rlang_error` method for the `knitr::sew()` generic is
#' registered to make it possible to display backtraces with
#' captured errors (`error = TRUE` chunks).
#'
#' In `error = TRUE` chunks, the default value for
#' `rlang_backtrace_on_error` is `"none"`. You can override it by
#' setting this option in your document, e.g. to `"reminder"` or
#' `"full"`.
#'
#' If you display rlang backtraces in a knitted document, you will
#' probably want to trim the knitr context from the backtrace by
#' setting this option in a hidden chunk:
#'
#' ```
#' options(
#' rlang_trace_top_env = environment()
#' )
#' ```
#'
#' @inheritParams cnd
#' @param message The message to display. Character vectors are
Expand Down Expand Up @@ -177,7 +197,7 @@ abort <- function(message = NULL,
}

signal_abort <- function(cnd) {
if (is_true(peek_option("rlang:::force_unhandled_error"))) {
if (is_true(peek_option("rlang::::force_unhandled_error"))) {
# Fall back with the full rlang error
fallback <- cnd
} else {
Expand All @@ -197,21 +217,17 @@ signal_abort <- function(cnd) {

if (is_interactive()) {
# Generate the error message, possibly with a backtrace or reminder
fallback$message <- paste_line(
conditionMessage(cnd),
format_onerror_backtrace(cnd)
)
fallback$message <- cnd_unhandled_message(cnd)
fallback$rlang_entraced <- TRUE
} else {
file <- peek_option("rlang:::error_pipe") %||% stderr()
msg <- conditionMessage(cnd)
fallback$message <- msg
fallback$message <- conditionMessage(cnd)

cat("Error: ", msg, "\n", sep = "", file = file)
msg <- cnd_unhandled_message(cnd)

# Print the backtrace eagerly in non-interactive sessions because
# the length of error messages is limited (#856)
cat(format_onerror_backtrace(cnd), "\n", sep = "", file = file)
cat("Error: ", msg, "\n", sep = "", file = file)

# Turn off the regular error printing to avoid printing the error
# twice
Expand All @@ -220,6 +236,36 @@ signal_abort <- function(cnd) {

stop(fallback)
}
cnd_unhandled_message <- function(cnd) {
paste_line(
conditionMessage(cnd),
format_onerror_backtrace(cnd)
)
}

on_load({
s3_register("knitr::sew", "rlang_error", function(x, options, ...) {
# Simulate interactive session to prevent full backtrace from
# being included in error message
local_interactive()

# Save the unhandled error for `rlang::last_error()`.
last_error_env$cnd <- x

# By default, we display no reminder or backtrace for errors
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about errors not captured by knitr?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As before they fall through the normal case and display a reminder in interactive sessions, or a backtrace in non-interactive sessions:

Quitting from lines 34-36 (test-trace-full.Rmd) 
Error: foo
Backtrace:1. └─global::f()
 2.   └─global::g()
 3.     └─global::h()

# captured by knitr. This default can be overridden.
opt <- peek_option("rlang_backtrace_on_error") %||% "none"
local_options(rlang_backtrace_on_error = opt)

msg <- cnd_unhandled_message(x)

# Create bare error and sew it to delegate finalisation to parent
# method since there is no simple way to generically modify the
# condition and then call `NextMethod()` (a `conditionMessage()`
# method might conflict, etc).
knitr::sew(simpleError(msg), options, ...)
})
})

trace_trim_context <- function(trace, idx) {
if (!is_scalar_integerish(idx)) {
Expand Down Expand Up @@ -410,7 +456,8 @@ show_trace_p <- function() {
#' @export
last_error <- function() {
if (is_null(last_error_env$cnd)) {
abort("Can't show last error because no error was recorded yet")
local_options(rlang_backtrace_on_error = "none")
stop("Can't show last error because no error was recorded yet", call. = FALSE)
}

cnd <- last_error_env$cnd
Expand Down
19 changes: 19 additions & 0 deletions man/abort.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 0 additions & 3 deletions tests/testthat/_snaps/cnd-abort.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
cat_line(reminder)
Output
Error: Error message

Execution halted
Code
cat_line(branch)
Expand Down Expand Up @@ -85,13 +84,11 @@
cat_line(branch_depth_0)
Output
Error: foo

Execution halted
Code
cat_line(full_depth_0)
Output
Error: foo

Execution halted
Code
cat_line(branch_depth_1)
Expand Down
53 changes: 53 additions & 0 deletions tests/testthat/_snaps/trace.md
Original file line number Diff line number Diff line change
Expand Up @@ -937,3 +937,56 @@
x
1. \-rlang:::f(3) test-trace.R:488:2

# caught error does not display backtrace in knitted files

Code
cat_line(render_md("test-trace-full.Rmd"))
Output
options(rlang_trace_top_env = environment())

f <- function() g()
g <- function() h()
h <- function() rlang::abort("foo")

f()

## Error: foo

Currently needs to be in a different chunk:

last_error()

## <error/rlang_error>
## foo
## Backtrace:
## 1. global::f()
## 2. global::g()
## 3. global::h()
## Run `rlang::last_trace()` to see the full context.

last_trace()

## <error/rlang_error>
## foo
## Backtrace:
## x
## 1. \-global::f()
## 2. \-global::g()
## 3. \-global::h()

options(rlang_backtrace_on_error = "reminder")
f()

## Error: foo
## Run `rlang::last_error()` to see where the error occurred.

options(rlang_backtrace_on_error = "full")
f()

## Error: foo
## Backtrace:
## x
## 1. \-global::f()
## 2. \-global::g()
## 3. \-global::h()

4 changes: 2 additions & 2 deletions tests/testthat/test-cnd-abort.R
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ test_that("errors are saved", {
# Verbose try() triggers conditionMessage() and thus saves the error.
# This simulates an unhandled error.
local_options(
`rlang:::force_unhandled_error` = TRUE,
`rlang::::force_unhandled_error` = TRUE,
`rlang:::error_pipe` = tempfile()
)

Expand All @@ -42,7 +42,7 @@ test_that("No backtrace is displayed with top-level active bindings", {

test_that("Invalid on_error option resets itself", {
with_options(
`rlang:::force_unhandled_error` = TRUE,
`rlang::::force_unhandled_error` = TRUE,
`rlang:::error_pipe` = tempfile(),
rlang_backtrace_on_error = NA,
{
Expand Down
2 changes: 1 addition & 1 deletion tests/testthat/test-cnd-error.R
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ test_that("error is printed with parent backtrace", {

err_force <- with_options(
catch_error(a()),
`rlang:::force_unhandled_error` = TRUE,
`rlang::::force_unhandled_error` = TRUE,
`rlang:::error_pipe` = tempfile()
)

Expand Down
28 changes: 28 additions & 0 deletions tests/testthat/test-trace-full.Rmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
```{r}
options(rlang_trace_top_env = environment())

f <- function() g()
g <- function() h()
h <- function() rlang::abort("foo")
```

```{r, error = TRUE}
f()
```

Currently needs to be in a different chunk:

```{r}
last_error()
last_trace()
```

```{r, error = TRUE}
options(rlang_backtrace_on_error = "reminder")
f()
```

```{r, error = TRUE}
options(rlang_backtrace_on_error = "full")
f()
```
5 changes: 5 additions & 0 deletions tests/testthat/test-trace.R
Original file line number Diff line number Diff line change
Expand Up @@ -600,9 +600,14 @@ test_that("caught error does not display backtrace in knitted files", {
rlang_backtrace_on_error = NULL,
rlang_interactive = FALSE
)

lines <- render_md("test-trace.Rmd")
error_line <- lines[[length(lines)]]
expect_match(error_line, "foo$")

expect_snapshot({
cat_line(render_md("test-trace-full.Rmd"))
})
})

test_that("empty backtraces are dealt with", {
Expand Down
8 changes: 4 additions & 4 deletions tests/testthat/test-trace.Rmd
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@

```{r}
getOption("rlang_backtrace_on_error")
```
options(rlang_trace_top_env = environment())

```{r, error = TRUE}
f <- function() g()
g <- function() h()
h <- function() rlang::abort("foo")
```

```{r, error = TRUE}
f()
```