Context managers

library(garnish)

Many resources come as a pair: something you acquire, and something you must release even if the code in between fails. Temporary directories, database connections, locks, changed options, open sinks. Python has with and @contextlib.contextmanager for this. contextmanager is the R version, and this vignette explains what it gives you over on.exit() and withr, because the honest answer is “less than you might hope, and more than it first looks”.

The problem with on.exit()

on.exit() runs when the function it is written in returns. That is exactly wrong for a helper that acquires a resource for its caller:

make_tempdir <- function() {
  d <- tempfile()
  dir.create(d)
  on.exit(unlink(d, recursive = TRUE))
  d
}
dir.exists(make_tempdir())
#> [1] FALSE

The directory is gone before the caller sees it. So the acquire and release lines get copied into every function that needs the resource, and forgetting the second one leaks.

What withr already does

withr solves this with defer(), which attaches cleanup to another function’s frame. The pattern is a local_ function, whose cleanup runs when the calling function exits, and often a with_ twin for a scoped block:

local_tempdir_withr <- function(.local_envir = parent.frame()) {
  d <- tempfile()
  dir.create(d)
  withr::defer(unlink(d, recursive = TRUE), envir = .local_envir)
  d
}

f <- function() {
  d <- local_tempdir_withr()
  dir.exists(d)
}
c(inside = f())
#> inside 
#>   TRUE 

.local_envir = parent.frame() is the whole trick. A default argument is evaluated in the function it belongs to, so parent.frame() there means “the function that called me”. Pass that frame to defer() and the cleanup waits for the caller.

If this is all you need, use withr. contextmanager is built on defer().

What contextmanager adds

You write the setup once, calling teardown() straight after each acquire, and return the resource. R has no yield, so this is the closest equivalent:

local_tempdir := contextmanager %@% function(pattern = "tmp") {
  path <- tempfile(pattern)
  dir.create(path)
  teardown(unlink(path, recursive = TRUE))
  path
}

That single definition works three ways. Called inside a function, it cleans up when that function exits:

f <- function() {
  d <- local_tempdir()
  writeLines("hi", file.path(d, "x.txt"))
  d
}
d <- f()
dir.exists(d)
#> [1] FALSE

with_context() scopes it to a block and hands the value to a function:

with_context(local_tempdir(), function(d) list.files(d))
#> character(0)

as_decorator() runs a whole function inside the context:

g := as_decorator(local_tempdir) %@% function() "ran inside the context"
g()
#> [1] "ran inside the context"

None of those is hard to write by hand for one resource. The advantage appears when there are several.

A worked comparison

Take a fake database API with a connection, and a scratch table that only makes sense on a connection. We want to be able to use the scratch table in all three ways.

connect <- function(name) {
  cat("  connect", name, "\n")
  list(name = name)
}
disconnect <- function(con) cat("  disconnect", con$name, "\n")
create_table <- function(con, t) cat("  create", t, "\n")
drop_table <- function(con, t) cat("  drop", t, "\n")

With withr, by hand

local_con <- function(name, .local_envir = parent.frame()) {
  con <- connect(name)
  withr::defer(disconnect(con), envir = .local_envir)
  con
}

local_scratch <- function(name, .local_envir = parent.frame()) {
  con <- local_con(name, .local_envir)
  create_table(con, "scratch")
  withr::defer(drop_table(con, "scratch"), envir = .local_envir)
  con
}

# The scoped form is a second copy of the same logic.
with_scratch <- function(name, code) {
  con <- connect(name)
  on.exit(disconnect(con))
  create_table(con, "scratch")
  on.exit(drop_table(con, "scratch"), add = TRUE, after = FALSE)
  code(con)
}

Things to get right: pass .local_envir on to the inner helper, or the connection closes as soon as local_scratch() returns. Write after = FALSE, or the connection closes before the table is dropped. And keep the local_ and with_ versions in step as the setup changes.

f <- function() {
  con <- local_scratch("db")
  cat("  using", con$name, "\n")
}
f()
#>   connect db 
#>   create scratch 
#>   using db 
#>   drop scratch 
#>   disconnect db 

With contextmanager

local_con2 := contextmanager %@% function(name) {
  con <- connect(name)
  teardown(disconnect(con))
  con
}

local_scratch2 := contextmanager %@% function(name) {
  con <- local_con2(name)
  create_table(con, "scratch")
  teardown(drop_table(con, "scratch"))
  con
}

The same output, from half the code:

f2 <- function() {
  con <- local_scratch2("db")
  cat("  using", con$name, "\n")
}
f2()
#>   connect db 
#>   create scratch 
#>   using db 
#>   drop scratch 
#>   disconnect db 

And the other two forms come from the same definition:

with_context(local_scratch2("db"), function(con) cat("  using", con$name, "\n"))
#>   connect db 
#>   create scratch 
#>   using db 
#>   drop scratch 
#>   disconnect db 
job := as_decorator(local_scratch2, "db") %@% function() cat("  running job\n")
job()
#>   connect db 
#>   create scratch 
#>   running job
#>   drop scratch 
#>   disconnect db 

What did the work:

When not to use it

The best fit is a package with several acquire/release helpers, some built on others, where callers want more than one style.

Behaviour worth knowing

If setup fails halfway, what was registered still runs.

local_partial := contextmanager %@% function() {
  teardown(cat("  released the first thing\n"))
  stop("second step failed")
}
try(with_context(local_partial(), identity), silent = TRUE)
#>   released the first thing

The cleanup runs when the caller exits. If the caller catches the error itself with tryCatch() and carries on, cleanup waits until that function returns.

There is no way to see the error in a teardown. Python’s generator can catch an exception at the yield. defer() cannot, so a context cannot choose to commit on success and roll back on failure. Handle that in the code that calls the context, with tryCatch().

Why not a generator? coro::generator() has a real yield, which makes it tempting. But coro does not support tryCatch(finally = ) around a yield, so a generator cannot guarantee its teardown runs. teardown() needs nothing but defer().

Top level. Calling a context at the console works. Cleanup is run by withr when the session ends.

How it works

contextmanager replaces the function’s signature with function(..., .local_envir = <caller>) and forwards ... to your setup. When you call teardown(expr), it searches the call stack for the nearest running context manager, reads that call’s .local_envir, and hands the expression to withr::defer() for that frame. Because the call stack is the record of which contexts are running, there is no global state to keep in step.