Skip to content

Commit

Permalink
Merge pull request #1215 from lionel-/fix-last-error-knitr
Browse files Browse the repository at this point in the history
Register an `rlang_error` method for `knitr::sew()` to handle backtraces in error messages
  • Loading branch information
lionel- committed May 21, 2021
2 parents e417116 + 035852b commit f43ccec
Show file tree
Hide file tree
Showing 10 changed files with 187 additions and 20 deletions.
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
# 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()
```

0 comments on commit f43ccec

Please sign in to comment.