From 6ecda38c0c32e197b1873493e4dc57b045e53544 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 2 Jun 2026 11:10:52 -0400 Subject: [PATCH 01/31] feat(setup): add exported setup() stub (#68) --- NAMESPACE | 1 + R/setup.R | 38 +++++++++++++++++++++++++++++++ man/setup.Rd | 45 +++++++++++++++++++++++++++++++++++++ tests/testthat/test-setup.R | 9 ++++++++ 4 files changed, 93 insertions(+) create mode 100644 R/setup.R create mode 100644 man/setup.Rd create mode 100644 tests/testthat/test-setup.R diff --git a/NAMESPACE b/NAMESPACE index 9939ed9..fd8c76d 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -21,5 +21,6 @@ export(gen) export(generator) export(is_exhausted) export(loop) +export(setup) export(yield) import(rlang) diff --git a/R/setup.R b/R/setup.R new file mode 100644 index 0000000..12f8423 --- /dev/null +++ b/R/setup.R @@ -0,0 +1,38 @@ +#' Set up per-step state in a coroutine +#' +#' @description +#' `setup()` registers an expression that runs at the start of **every** step of +#' a [generator()] or [async()] function: the initial entry and every resumption +#' after a [yield()] or [await()]. Any `withr::defer()`, `withr::local_*()`, or +#' [on.exit()] registered while `expr` runs is torn down at the **end of that +#' step** (the next `yield`/`await`, a `return`, normal completion, an error, or +#' early close). +#' +#' This gives setup/teardown parity for each step of a coroutine, unlike a +#' top-level `on.exit()` which only fires once when the whole function exits. +#' +#' @details +#' - `expr` runs in an isolated environment: it can read the function's +#' arguments, locals, and lexical scope, and mutate external state +#' (`the$x <- 1`, `<<-`), but **plain assignments stay local to `expr`** and are +#' not visible to the function body. +#' - When execution reaches a `setup()` call it is registered and runs for the +#' current step; re-encountering the *same* `setup()` call (e.g. on a later loop +#' iteration) is a no-op. A `setup()` inside an `if`/loop registers only if and +#' when reached. +#' - Multiple `setup()` calls stack and run in registration order each step; their +#' teardowns fire in reverse order. +#' - `setup()` cannot contain `yield()`/`await()` and its result cannot be +#' assigned. +#' +#' Like [yield()] and [await()], `setup()` is a syntactic construct recognised by +#' the coroutine compiler. Calling it directly (outside a coroutine body) is an +#' error. +#' +#' @param expr An expression to run at the start of each step. +#' +#' @seealso [generator()], [async()], [yield()], [await()]. +#' @export +setup <- function(expr) { + abort("`setup()` can't be called directly or within function arguments.") +} diff --git a/man/setup.Rd b/man/setup.Rd new file mode 100644 index 0000000..bd47ccd --- /dev/null +++ b/man/setup.Rd @@ -0,0 +1,45 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/setup.R +\name{setup} +\alias{setup} +\title{Set up per-step state in a coroutine} +\usage{ +setup(expr) +} +\arguments{ +\item{expr}{An expression to run at the start of each step.} +} +\description{ +\code{setup()} registers an expression that runs at the start of \strong{every} step of +a \code{\link[=generator]{generator()}} or \code{\link[=async]{async()}} function: the initial entry and every resumption +after a \code{\link[=yield]{yield()}} or \code{\link[=await]{await()}}. Any \code{withr::defer()}, \verb{withr::local_*()}, or +\code{\link[=on.exit]{on.exit()}} registered while \code{expr} runs is torn down at the \strong{end of that +step} (the next \code{yield}/\code{await}, a \code{return}, normal completion, an error, or +early close). + +This gives setup/teardown parity for each step of a coroutine, unlike a +top-level \code{on.exit()} which only fires once when the whole function exits. +} +\details{ +\itemize{ +\item \code{expr} runs in an isolated environment: it can read the function's +arguments, locals, and lexical scope, and mutate external state +(\code{the$x <- 1}, \verb{<<-}), but \strong{plain assignments stay local to \code{expr}} and are +not visible to the function body. +\item When execution reaches a \code{setup()} call it is registered and runs for the +current step; re-encountering the \emph{same} \code{setup()} call (e.g. on a later loop +iteration) is a no-op. A \code{setup()} inside an \code{if}/loop registers only if and +when reached. +\item Multiple \code{setup()} calls stack and run in registration order each step; their +teardowns fire in reverse order. +\item \code{setup()} cannot contain \code{yield()}/\code{await()} and its result cannot be +assigned. +} + +Like \code{\link[=yield]{yield()}} and \code{\link[=await]{await()}}, \code{setup()} is a syntactic construct recognised by +the coroutine compiler. Calling it directly (outside a coroutine body) is an +error. +} +\seealso{ +\code{\link[=generator]{generator()}}, \code{\link[=async]{async()}}, \code{\link[=yield]{yield()}}, \code{\link[=await]{await()}}. +} diff --git a/tests/testthat/test-setup.R b/tests/testthat/test-setup.R new file mode 100644 index 0000000..1557edf --- /dev/null +++ b/tests/testthat/test-setup.R @@ -0,0 +1,9 @@ +test_that("setup() can't be called directly or within function arguments", { + expect_error(setup(1), "called directly") + + f <- generator(function() { + list(setup(1)) + yield(2) + }) + expect_error(f()(), "within function arguments") +}) From e27617b5ca15355d29ea92e79a6e607b4c53f6dc Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 2 Jun 2026 11:17:19 -0400 Subject: [PATCH 02/31] feat(setup): compile and run per-step setup/teardown (#68) --- R/generator.R | 57 ++++++++++++++++++++++++++++++++++ R/parser.R | 61 ++++++++++++++++++++++++++++++++++++- tests/testthat/test-setup.R | 27 ++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/R/generator.R b/R/generator.R index 86b91ee..aacec01 100644 --- a/R/generator.R +++ b/R/generator.R @@ -289,7 +289,9 @@ generator0 <- function(fn, type = "generator") { out <- evalq(envir = user_env, { base::evalq(envir = rlang::wref_key(!!weak_env), { defer(if (exited) cleanup()) + defer(run_step_teardowns()) env_poke_exits(user_env, exits) + run_setups() !!state_machine }) }) @@ -347,6 +349,9 @@ new_generator_env <- function(parent, info) { env$exits <- NULL env$exited <- TRUE env$.last_value <- NULL + env$setups <- list() + env$setup_ids <- integer() + env$step_teardowns <- list() with(env, { user <- function(expr) { @@ -360,6 +365,58 @@ new_generator_env <- function(parent, info) { exited <<- FALSE exits <<- env_poke_exits(user_env, NULL) } + + run_one_setup <- function(expr) { + e <- new.env(parent = user_env) + harvester <- new_function( + pairlist2(), + block( + expr, + quote(`.__coro_setup_exits__` <- sys.on.exit()), + quote(on.exit()), + quote(`.__coro_setup_exits__`) + ), + env = e + ) + td <- list(env = e, exits = harvester()) + step_teardowns <<- c(step_teardowns, list(td)) + } + + do_setup <- function(id, expr) { + if (id %in% setup_ids) { + return(invisible(NULL)) + } + setup_ids <<- c(setup_ids, id) + setups <<- c(setups, list(expr)) + run_one_setup(expr) + } + + run_setups <- function() { + step_teardowns <<- list() + for (expr in setups) { + run_one_setup(expr) + } + } + + run_step_teardowns <- function() { + err <- NULL + for (td in rev(step_teardowns)) { + if (is_null(td$exits)) { + next + } + exprs <- if (is_call(td$exits, "{")) as.list(td$exits)[-1] else list(td$exits) + for (ex in exprs) { + tryCatch( + eval_bare(ex, td$env), + error = function(cnd) if (is_null(err)) err <<- cnd + ) + } + } + step_teardowns <<- list() + if (!is_null(err)) { + cnd_signal(err) + } + } }) if (!is_null(info$async_ops)) { diff --git a/R/parser.R b/R/parser.R index e47c704..e215458 100644 --- a/R/parser.R +++ b/R/parser.R @@ -154,6 +154,15 @@ expr_states <- function(expr, counter, continue, last, return, info) { info = info, assign_var = yield_lhs(expr, assign) ), + `setup` = setup_state( + preamble = NULL, + expr = node_cadr(expr), + counter = counter, + continue = continue, + last = last, + return = return, + info = info + ), `return` = return_state( expr = user_call(strip_explicit_return(expr)), counter = counter, @@ -262,7 +271,7 @@ expr_type_impl <- function(expr) { head <- node_car(expr) if (is_call(head, "::") && identical(head[[2]], quote(coro))) { head <- head[[3]] - if (!as_string(head) %in% c("yield", "await", "await_each")) { + if (!as_string(head) %in% c("yield", "await", "await_each", "setup")) { return(default) } } @@ -277,6 +286,7 @@ expr_type_impl <- function(expr) { `{` = , `yield` = , `await` = , + `setup` = , `return` = , `if` = , `repeat` = , @@ -466,6 +476,19 @@ block_states <- function(block, counter, continue, last, return, info) { )) next }, + `setup` = { + skip() + push_states(setup_state( + preamble = collect(), + expr = node_cadr(expr), + counter = counter, + continue = continue, + last = last, + return = return, + info = info + )) + next + }, `repeat` = { node_poke_car(node, "repeat") push_states(loop_states( @@ -1032,6 +1055,42 @@ next_state <- function(preamble, counter, info) { state } +setup_state <- function(preamble, expr, counter, continue, last, return, info) { + check_setup_body(expr) + + i <- counter() + next_i <- continue(counter, last) + depth <- machine_depth(counter) + + block <- expr({ + !!!preamble %&&% list(user_call(preamble)) + do_setup(!!i, !!call2("quote", expr)) + !!continue_call(next_i, depth) + }) + state <- new_state(block, NULL, tag = i) + counter(inc = 1L) + + state +} + +# `setup()` bodies run as plain code outside the state machine, so they can't +# suspend. Reject `yield()`/`await()`/`await_each()` at compile time. Don't +# descend into nested `function` definitions (a nested generator/async is fine). +check_setup_body <- function(expr) { + if (!is_call(expr) || is_call(expr, "function")) { + return(invisible()) + } + for (nm in c("yield", "await", "await_each")) { + if (is_call(expr, nm, ns = c("", "coro"))) { + abort(sprintf("Can't use `%s()` within `setup()`.", nm)) + } + } + for (arg in as.list(expr)[-1]) { + check_setup_body(arg) + } + invisible() +} + try_catch_states <- function( preamble, expr, diff --git a/tests/testthat/test-setup.R b/tests/testthat/test-setup.R index 1557edf..0cacb72 100644 --- a/tests/testthat/test-setup.R +++ b/tests/testthat/test-setup.R @@ -7,3 +7,30 @@ test_that("setup() can't be called directly or within function arguments", { }) expect_error(f()(), "within function arguments") }) + +test_that("setup() runs before every step; teardown fires at each step end", { + the <- new.env() + the$x <- 0 + log <- character() + + gen <- generator(function() { + setup({ + old <- the$x + the$x <- 9 + withr::defer(the$x <- old) + }) + log <<- c(log, paste0("before1:", the$x)) + yield(1) + log <<- c(log, paste0("before2:", the$x)) + yield(2) + }) + g <- gen() + + expect_equal(g(), 1) # step 1 ran setup, yielded 1 + expect_equal(the$x, 0) # step 1 teardown restored x + expect_equal(g(), 2) # step 2 re-ran setup, yielded 2 + expect_equal(the$x, 0) # step 2 teardown restored x + expect_exhausted(g()) # step 3 (after yield 2) ran setup + teardown + expect_equal(the$x, 0) + expect_equal(log, c("before1:9", "before2:9")) +}) From a55bf6b73046fb7c56ca91fc00728a40cfb16a1d Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 2 Jun 2026 11:23:59 -0400 Subject: [PATCH 03/31] docs(setup): explain step-teardown defer ordering (#68) --- R/generator.R | 3 +++ 1 file changed, 3 insertions(+) diff --git a/R/generator.R b/R/generator.R index aacec01..eedc120 100644 --- a/R/generator.R +++ b/R/generator.R @@ -289,6 +289,9 @@ generator0 <- function(fn, type = "generator") { out <- evalq(envir = user_env, { base::evalq(envir = rlang::wref_key(!!weak_env), { defer(if (exited) cleanup()) + # `setup()` teardowns fire at every step end (suspend/return/error). + # Registered as a separate, later `defer()` so they run before + # `cleanup()` (LIFO) yet can't prevent `cleanup()` if one errors. defer(run_step_teardowns()) env_poke_exits(user_env, exits) run_setups() From 4475d0d6a7a5df9e02c98b05c3878deeec6114c1 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 2 Jun 2026 11:28:39 -0400 Subject: [PATCH 04/31] test(setup): cover async await() teardown (#68) --- tests/testthat/test-setup.R | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/testthat/test-setup.R b/tests/testthat/test-setup.R index 0cacb72..7073a5b 100644 --- a/tests/testthat/test-setup.R +++ b/tests/testthat/test-setup.R @@ -34,3 +34,28 @@ test_that("setup() runs before every step; teardown fires at each step end", { expect_equal(the$x, 0) expect_equal(log, c("before1:9", "before2:9")) }) + +test_that("setup() teardown is restored around await() (issue #68 reprex)", { + skip_on_cran() + the <- new.env() + the$x <- 0 + seen <- list() + + f <- async(function(x) { + setup({ + old_x <- the$x + the$x <- x + withr::defer(the$x <- old_x) + }) + seen$before <<- c(seen$before, the$x) + await(async_sleep(0)) + seen$after <<- c(seen$after, the$x) + the$x + }) + + out <- wait_for(f(1)) + expect_equal(out, 1) + expect_equal(seen$before, 1) + expect_equal(seen$after, 1) + expect_equal(the$x, 0) +}) From 0d56af6b5a147755d5db2a26ce07aca6cf28ad4b Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 2 Jun 2026 11:28:46 -0400 Subject: [PATCH 05/31] test(setup): cover stacked setups and reverse teardown order (#68) --- tests/testthat/test-setup.R | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/testthat/test-setup.R b/tests/testthat/test-setup.R index 7073a5b..525b17a 100644 --- a/tests/testthat/test-setup.R +++ b/tests/testthat/test-setup.R @@ -59,3 +59,18 @@ test_that("setup() teardown is restored around await() (issue #68 reprex)", { expect_equal(seen$after, 1) expect_equal(the$x, 0) }) + +test_that("multiple setup() calls stack; teardowns fire in reverse order", { + log <- character() + + gen <- generator(function() { + setup(withr::defer(log <<- c(log, "teardown-A"))) + setup(withr::defer(log <<- c(log, "teardown-B"))) + log <<- c(log, "body") + yield(1) + }) + g <- gen() + + expect_equal(g(), 1) + expect_equal(log, c("body", "teardown-B", "teardown-A")) +}) From 5fd85e10780b557e2af35337e935014e9ef4d763 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 2 Jun 2026 11:28:56 -0400 Subject: [PATCH 06/31] test(setup): cover teardown isolation (#68) --- tests/testthat/test-setup.R | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/testthat/test-setup.R b/tests/testthat/test-setup.R index 525b17a..c1fc9c7 100644 --- a/tests/testthat/test-setup.R +++ b/tests/testthat/test-setup.R @@ -74,3 +74,20 @@ test_that("multiple setup() calls stack; teardowns fire in reverse order", { expect_equal(g(), 1) expect_equal(log, c("body", "teardown-B", "teardown-A")) }) + +test_that("a failing teardown does not block other teardowns; first error re-raised", { + log <- character() + + gen <- generator(function() { + setup({ + withr::defer(log <<- c(log, "A-1")) + withr::defer(stop("boom in A")) # registered 2nd -> runs 1st within A + }) + setup(withr::defer(log <<- c(log, "B"))) + yield(1) + }) + g <- gen() + + expect_error(g(), "boom in A") + expect_equal(log, c("B", "A-1")) +}) From 88d1c3599dc81406b9f88e4d680d9668f7d9d119 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 2 Jun 2026 11:29:05 -0400 Subject: [PATCH 07/31] test(setup): cover compile-time guards (#68) --- tests/testthat/test-setup.R | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/testthat/test-setup.R b/tests/testthat/test-setup.R index c1fc9c7..32100ee 100644 --- a/tests/testthat/test-setup.R +++ b/tests/testthat/test-setup.R @@ -91,3 +91,32 @@ test_that("a failing teardown does not block other teardowns; first error re-rai expect_error(g(), "boom in A") expect_equal(log, c("B", "A-1")) }) + +test_that("setup() rejects suspension and assignment at compile time", { + expect_error(generator(function() setup(yield(1)))(), "within `setup\\(\\)`") + expect_error(async(function() setup(await(1)))(), "within `setup\\(\\)`") + expect_error( + generator(function() { + setup(await_each(1)) + yield(1) + })(), + "within `setup\\(\\)`" + ) + expect_error( + generator(function() { + x <- setup(1) + yield(x) + })(), + "Can't assign the result of a `setup` expression" + ) +}) + +test_that("setup() allows a nested coroutine in its body", { + gen <- generator(function() { + setup({ + g2 <- generator(function() yield(1)) + }) + yield("ok") + }) + expect_equal(gen()(), "ok") +}) From 0199b2536c736a76adfe2f1a21ef40e927579fa6 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 2 Jun 2026 11:29:26 -0400 Subject: [PATCH 08/31] test(setup): pin known shortcomings with co-located tests (#68) --- tests/testthat/test-setup.R | 85 +++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/tests/testthat/test-setup.R b/tests/testthat/test-setup.R index 32100ee..9241f7e 100644 --- a/tests/testthat/test-setup.R +++ b/tests/testthat/test-setup.R @@ -120,3 +120,88 @@ test_that("setup() allows a nested coroutine in its body", { }) expect_equal(gen()(), "ok") }) + +# Known shortcomings ---- +# These tests document *deliberate* limitations / surprising-but-correct +# behaviors of setup(). They assert the current behavior on purpose. + +test_that("KNOWN LIMITATION: plain assignments in setup() are not visible to the body", { + gen <- generator(function() { + setup({ + y <- 99 + }) + yield(exists("y", inherits = FALSE)) + }) + expect_false(gen()()) +}) + +test_that("KNOWN LIMITATION: a setup() after a suspend is not retroactive", { + log <- character() + gen <- generator(function() { + log <<- c(log, "step1") + yield(1) + setup(log <<- c(log, "setup-registered")) + yield(2) + }) + g <- gen() + g() + expect_false("setup-registered" %in% log) + g() + expect_true("setup-registered" %in% log) +}) + +test_that("KNOWN LIMITATION: setup() in a loop is per-step, not per-iteration", { + runs <- 0L + gen <- generator(function() { + for (i in 1:3) { + setup(runs <<- runs + 1L) + yield(i) + } + }) + g <- gen() + g(); g(); g() + expect_equal(runs, 3L) +}) + +test_that("KNOWN LIMITATION: setup() registration is sticky across branches", { + runs <- 0L + gen <- generator(function() { + for (i in 1:3) { + if (i == 1) { + setup(runs <<- runs + 1L) + } + yield(i) + } + }) + g <- gen() + g(); g(); g() + expect_equal(runs, 3L) +}) + +test_that("KNOWN LIMITATION: a teardown error disables the generator", { + gen <- generator(function() { + setup(withr::defer(stop("teardown boom"))) + yield(1) + yield(2) + }) + g <- gen() + expect_error(g(), "teardown boom") + expect_error(g(), "disabled") +}) + +test_that("KNOWN LIMITATION: an abandoned async promise leaves no final step", { + skip_on_cran() + the <- new.env() + the$x <- 0 + f <- async(function() { + setup({ + old <- the$x + the$x <- 1 + withr::defer(the$x <- old) + }) + await(promises::promise(function(resolve, reject) NULL)) + the$x <- 999 + }) + prom <- f() + expect_equal(the$x, 0) +}) From 3f351c33987f58425ac4a901bb47cb4908e17c4a Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 2 Jun 2026 11:29:41 -0400 Subject: [PATCH 09/31] test(setup): snapshot compiled state machine (#68) --- tests/testthat/_snaps/setup.md | 41 ++++++++++++++++++++++++++++++++++ tests/testthat/test-setup.R | 10 +++++++++ 2 files changed, 51 insertions(+) create mode 100644 tests/testthat/_snaps/setup.md diff --git a/tests/testthat/_snaps/setup.md b/tests/testthat/_snaps/setup.md new file mode 100644 index 0000000..405f063 --- /dev/null +++ b/tests/testthat/_snaps/setup.md @@ -0,0 +1,41 @@ +# setup() compiles to a do_setup() state + + Code + generator_body(function() { + setup({ + old <- the$x + withr::defer(the$x <- old) + }) + yield(1) + }) + Output + { + if (exhausted) { + return(invisible(exhausted())) + } + repeat { + switch(state[[1L]], `1` = { + do_setup(1L, quote({ + old <- the$x + withr::defer(the$x <- old) + })) + state[[1L]] <- 2L + }, `2` = { + user({ + 1 + }) + state[[1L]] <- 3L + suspend() + return(last_value()) + }, `3` = { + .last_value <- if (missing(arg)) exhausted() else arg + state[[1L]] <- 4L + }, `4` = { + exhausted <- TRUE + return(exhausted()) + }) + } + exhausted <- TRUE + invisible(exhausted()) + } + diff --git a/tests/testthat/test-setup.R b/tests/testthat/test-setup.R index 9241f7e..83acde9 100644 --- a/tests/testthat/test-setup.R +++ b/tests/testthat/test-setup.R @@ -205,3 +205,13 @@ test_that("KNOWN LIMITATION: an abandoned async promise leaves no final step", { prom <- f() expect_equal(the$x, 0) }) + +test_that("setup() compiles to a do_setup() state", { + expect_snapshot0(generator_body(function() { + setup({ + old <- the$x + withr::defer(the$x <- old) + }) + yield(1) + })) +}) From dacd93abf4fd5c0ac1225e5c081d34a406b783df Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 2 Jun 2026 11:31:46 -0400 Subject: [PATCH 10/31] test(setup): use env (not complex-LHS `<<-`) in await reprex (#68) --- tests/testthat/test-setup.R | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/testthat/test-setup.R b/tests/testthat/test-setup.R index 83acde9..756e6d0 100644 --- a/tests/testthat/test-setup.R +++ b/tests/testthat/test-setup.R @@ -39,7 +39,7 @@ test_that("setup() teardown is restored around await() (issue #68 reprex)", { skip_on_cran() the <- new.env() the$x <- 0 - seen <- list() + seen <- new.env() f <- async(function(x) { setup({ @@ -47,9 +47,9 @@ test_that("setup() teardown is restored around await() (issue #68 reprex)", { the$x <- x withr::defer(the$x <- old_x) }) - seen$before <<- c(seen$before, the$x) + seen$before <- c(seen$before, the$x) await(async_sleep(0)) - seen$after <<- c(seen$after, the$x) + seen$after <- c(seen$after, the$x) the$x }) From af9e33c434027bcd6853f1780ddfb7a50eb2be30 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 2 Jun 2026 11:36:39 -0400 Subject: [PATCH 11/31] test(setup): clarify compile timing; make loop test discriminate per-step (#68) --- tests/testthat/test-setup.R | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/testthat/test-setup.R b/tests/testthat/test-setup.R index 756e6d0..bed456b 100644 --- a/tests/testthat/test-setup.R +++ b/tests/testthat/test-setup.R @@ -92,7 +92,9 @@ test_that("a failing teardown does not block other teardowns; first error re-rai expect_equal(log, c("B", "A-1")) }) -test_that("setup() rejects suspension and assignment at compile time", { +test_that("setup() rejects suspension and assignment when the coroutine is compiled", { + # The state machine is compiled lazily on the first instance call, so these + # errors surface at `f()` (the trailing `()`), not at factory definition. expect_error(generator(function() setup(yield(1)))(), "within `setup\\(\\)`") expect_error(async(function() setup(await(1)))(), "within `setup\\(\\)`") expect_error( @@ -153,14 +155,18 @@ test_that("KNOWN LIMITATION: a setup() after a suspend is not retroactive", { test_that("KNOWN LIMITATION: setup() in a loop is per-step, not per-iteration", { runs <- 0L gen <- generator(function() { - for (i in 1:3) { + for (i in 1:2) { setup(runs <<- runs + 1L) - yield(i) + yield(i) # 2 iterations x 2 yields = 4 steps total + yield(i * 10L) } }) g <- gen() - g(); g(); g() - expect_equal(runs, 3L) + g(); g(); g(); g() + # Runs once per *step* (4), not once per *iteration* (which would be 2): + # registered on the first encounter, re-running at the start of every step; + # the re-encounter of setup() on iteration 2 is a dedup no-op. + expect_equal(runs, 4L) }) test_that("KNOWN LIMITATION: setup() registration is sticky across branches", { From 7959add323124e13bd4941c31b46ebaeb5c10d28 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 2 Jun 2026 11:43:24 -0400 Subject: [PATCH 12/31] docs(setup): examples and NEWS for setup() (#68) Add @examples to setup(), add withr to Suggests (was used in tests but undeclared), and add NEWS bullet for setup(). --- DESCRIPTION | 3 ++- NEWS.md | 5 +++++ R/setup.R | 19 +++++++++++++++++++ man/setup.Rd | 20 ++++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index d0276ff..9cd8e01 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -27,7 +27,8 @@ Suggests: R6, reticulate, rmarkdown, - testthat (>= 3.0.0) + testthat (>= 3.0.0), + withr VignetteBuilder: knitr Config/Needs/website: tidyverse/tidytemplate diff --git a/NEWS.md b/NEWS.md index 980fbb3..3fabb31 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,10 @@ # coro (development version) +* New `setup()` registers an expression that runs at the start of every step of + a generator or async function, with any `withr::defer()`/`on.exit()` it + registers torn down at the end of that step. This gives per-step setup/teardown + parity, including around `await()`/`yield()` (#68). + * Async functions and generators can now be R6 methods (#63). * A memory leak that crept back in (see #36) was fixed with a more robust approach. diff --git a/R/setup.R b/R/setup.R index 12f8423..4511c93 100644 --- a/R/setup.R +++ b/R/setup.R @@ -32,6 +32,25 @@ #' @param expr An expression to run at the start of each step. #' #' @seealso [generator()], [async()], [yield()], [await()]. +#' @examples +#' the <- new.env() +#' the$x <- 0 +#' +#' gen <- generator(function() { +#' setup({ +#' old_x <- the$x +#' the$x <- 1 +#' withr::defer(the$x <- old_x) +#' }) +#' yield(the$x) # 1 while the step runs +#' yield(the$x) # 1 again: setup re-ran for this step +#' }) +#' +#' g <- gen() +#' g() # 1 +#' the$x # 0 — restored at the end of the step +#' g() # 1 +#' the$x # 0 #' @export setup <- function(expr) { abort("`setup()` can't be called directly or within function arguments.") diff --git a/man/setup.Rd b/man/setup.Rd index bd47ccd..864aa99 100644 --- a/man/setup.Rd +++ b/man/setup.Rd @@ -40,6 +40,26 @@ Like \code{\link[=yield]{yield()}} and \code{\link[=await]{await()}}, \code{setu the coroutine compiler. Calling it directly (outside a coroutine body) is an error. } +\examples{ +the <- new.env() +the$x <- 0 + +gen <- generator(function() { + setup({ + old_x <- the$x + the$x <- 1 + withr::defer(the$x <- old_x) + }) + yield(the$x) # 1 while the step runs + yield(the$x) # 1 again: setup re-ran for this step +}) + +g <- gen() +g() # 1 +the$x # 0 — restored at the end of the step +g() # 1 +the$x # 0 +} \seealso{ \code{\link[=generator]{generator()}}, \code{\link[=async]{async()}}, \code{\link[=yield]{yield()}}, \code{\link[=await]{await()}}. } From 276cfed766fa4a3e676fbe5df22c4d43a7226d66 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 2 Jun 2026 11:48:29 -0400 Subject: [PATCH 13/31] fix(setup): declare runtime helpers to satisfy R CMD check (#68) --- R/generator.R | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/R/generator.R b/R/generator.R index eedc120..54dfebd 100644 --- a/R/generator.R +++ b/R/generator.R @@ -152,6 +152,8 @@ generator0 <- function(fn, type = "generator") { exited <- NULL cleanup <- NULL close_active_iterators <- NULL + run_setups <- NULL + run_step_teardowns <- NULL } generator_env <- environment()$generator_env @@ -561,5 +563,6 @@ utils::globalVariables(c( "user", "exits", "suspend", - "generator_env" + "generator_env", + "do_setup" )) From 05334e667edfce2f38e494bf60beb76a57304968 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 2 Jun 2026 13:39:07 -0400 Subject: [PATCH 14/31] docs(setup): add setup() to pkgdown reference index (#68) --- _pkgdown.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/_pkgdown.yml b/_pkgdown.yml index 3b37051..9ce7f6c 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -18,6 +18,7 @@ reference: - generator - gen - yield + - setup - loop - collect From 144e847dabb02ae87b1f4f025c2a7ddb28a6b0e8 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 2 Jun 2026 14:12:34 -0400 Subject: [PATCH 15/31] refactor(setup): drop withr dependency; teardowns eval in setup body frame (#68) Use base on.exit() instead of withr::defer() in tests and the example so coro gains no new package dependency (withr removed from Suggests). This exposed that teardowns must be evaluated in the setup body's own execution frame so a bare on.exit(x <- old) can resolve the body's locals; run_one_setup now captures and replays in that frame (withr::defer closures still work). --- DESCRIPTION | 3 +-- R/generator.R | 18 ++++++++++++------ R/setup.R | 2 +- man/setup.Rd | 2 +- tests/testthat/_snaps/setup.md | 4 ++-- tests/testthat/test-setup.R | 20 ++++++++++---------- 6 files changed, 27 insertions(+), 22 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 9cd8e01..d0276ff 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -27,8 +27,7 @@ Suggests: R6, reticulate, rmarkdown, - testthat (>= 3.0.0), - withr + testthat (>= 3.0.0) VignetteBuilder: knitr Config/Needs/website: tidyverse/tidytemplate diff --git a/R/generator.R b/R/generator.R index 54dfebd..82cc616 100644 --- a/R/generator.R +++ b/R/generator.R @@ -372,19 +372,25 @@ new_generator_env <- function(parent, info) { } run_one_setup <- function(expr) { - e <- new.env(parent = user_env) + # Run the setup body in a throwaway closure whose frame is an isolated + # child of `user_env`. Capture its `on.exit()`/`withr::defer()` + # registrations (without firing them) plus the frame itself, so the + # teardowns can be replayed at step end in the same environment where the + # body's locals live (a bare `on.exit(x <- old)` references those locals). harvester <- new_function( pairlist2(), block( expr, - quote(`.__coro_setup_exits__` <- sys.on.exit()), + quote(`.__coro_setup_td__` <- list( + env = environment(), + exits = sys.on.exit() + )), quote(on.exit()), - quote(`.__coro_setup_exits__`) + quote(`.__coro_setup_td__`) ), - env = e + env = new.env(parent = user_env) ) - td <- list(env = e, exits = harvester()) - step_teardowns <<- c(step_teardowns, list(td)) + step_teardowns <<- c(step_teardowns, list(harvester())) } do_setup <- function(id, expr) { diff --git a/R/setup.R b/R/setup.R index 4511c93..98f9899 100644 --- a/R/setup.R +++ b/R/setup.R @@ -40,7 +40,7 @@ #' setup({ #' old_x <- the$x #' the$x <- 1 -#' withr::defer(the$x <- old_x) +#' on.exit(the$x <- old_x, add = TRUE) #' }) #' yield(the$x) # 1 while the step runs #' yield(the$x) # 1 again: setup re-ran for this step diff --git a/man/setup.Rd b/man/setup.Rd index 864aa99..a553444 100644 --- a/man/setup.Rd +++ b/man/setup.Rd @@ -48,7 +48,7 @@ gen <- generator(function() { setup({ old_x <- the$x the$x <- 1 - withr::defer(the$x <- old_x) + on.exit(the$x <- old_x, add = TRUE) }) yield(the$x) # 1 while the step runs yield(the$x) # 1 again: setup re-ran for this step diff --git a/tests/testthat/_snaps/setup.md b/tests/testthat/_snaps/setup.md index 405f063..8a52f3f 100644 --- a/tests/testthat/_snaps/setup.md +++ b/tests/testthat/_snaps/setup.md @@ -4,7 +4,7 @@ generator_body(function() { setup({ old <- the$x - withr::defer(the$x <- old) + on.exit(the$x <- old, add = TRUE) }) yield(1) }) @@ -17,7 +17,7 @@ switch(state[[1L]], `1` = { do_setup(1L, quote({ old <- the$x - withr::defer(the$x <- old) + on.exit(the$x <- old, add = TRUE) })) state[[1L]] <- 2L }, `2` = { diff --git a/tests/testthat/test-setup.R b/tests/testthat/test-setup.R index bed456b..52e31c3 100644 --- a/tests/testthat/test-setup.R +++ b/tests/testthat/test-setup.R @@ -17,7 +17,7 @@ test_that("setup() runs before every step; teardown fires at each step end", { setup({ old <- the$x the$x <- 9 - withr::defer(the$x <- old) + on.exit(the$x <- old, add = TRUE) }) log <<- c(log, paste0("before1:", the$x)) yield(1) @@ -45,7 +45,7 @@ test_that("setup() teardown is restored around await() (issue #68 reprex)", { setup({ old_x <- the$x the$x <- x - withr::defer(the$x <- old_x) + on.exit(the$x <- old_x, add = TRUE) }) seen$before <- c(seen$before, the$x) await(async_sleep(0)) @@ -64,8 +64,8 @@ test_that("multiple setup() calls stack; teardowns fire in reverse order", { log <- character() gen <- generator(function() { - setup(withr::defer(log <<- c(log, "teardown-A"))) - setup(withr::defer(log <<- c(log, "teardown-B"))) + setup(on.exit(log <<- c(log, "teardown-A"), add = TRUE)) + setup(on.exit(log <<- c(log, "teardown-B"), add = TRUE)) log <<- c(log, "body") yield(1) }) @@ -80,10 +80,10 @@ test_that("a failing teardown does not block other teardowns; first error re-rai gen <- generator(function() { setup({ - withr::defer(log <<- c(log, "A-1")) - withr::defer(stop("boom in A")) # registered 2nd -> runs 1st within A + on.exit(log <<- c(log, "A-1"), add = TRUE) + on.exit(stop("boom in A"), add = TRUE) # both teardowns run; first error re-raised }) - setup(withr::defer(log <<- c(log, "B"))) + setup(on.exit(log <<- c(log, "B"), add = TRUE)) yield(1) }) g <- gen() @@ -186,7 +186,7 @@ test_that("KNOWN LIMITATION: setup() registration is sticky across branches", { test_that("KNOWN LIMITATION: a teardown error disables the generator", { gen <- generator(function() { - setup(withr::defer(stop("teardown boom"))) + setup(on.exit(stop("teardown boom"), add = TRUE)) yield(1) yield(2) }) @@ -203,7 +203,7 @@ test_that("KNOWN LIMITATION: an abandoned async promise leaves no final step", { setup({ old <- the$x the$x <- 1 - withr::defer(the$x <- old) + on.exit(the$x <- old, add = TRUE) }) await(promises::promise(function(resolve, reject) NULL)) the$x <- 999 @@ -216,7 +216,7 @@ test_that("setup() compiles to a do_setup() state", { expect_snapshot0(generator_body(function() { setup({ old <- the$x - withr::defer(the$x <- old) + on.exit(the$x <- old, add = TRUE) }) yield(1) })) From cf5c238b95b647e8631155eb2716cb500bcb74ad Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Tue, 2 Jun 2026 14:15:43 -0400 Subject: [PATCH 16/31] docs(setup): condense defer-ordering comment per review (#68) --- R/generator.R | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/R/generator.R b/R/generator.R index 82cc616..265116c 100644 --- a/R/generator.R +++ b/R/generator.R @@ -290,10 +290,9 @@ generator0 <- function(fn, type = "generator") { out <- evalq(envir = user_env, { base::evalq(envir = rlang::wref_key(!!weak_env), { + # LIFO, independent execution defer(if (exited) cleanup()) # `setup()` teardowns fire at every step end (suspend/return/error). - # Registered as a separate, later `defer()` so they run before - # `cleanup()` (LIFO) yet can't prevent `cleanup()` if one errors. defer(run_step_teardowns()) env_poke_exits(user_env, exits) run_setups() From 169340c55a16b5cd4a975352a1c2a5f3c7c2a8dd Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 4 Jun 2026 16:07:40 -0400 Subject: [PATCH 17/31] test(setup): add withr::defer/local_* contract test (#68) --- DESCRIPTION | 3 ++- tests/testthat/test-setup.R | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index d0276ff..9cd8e01 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -27,7 +27,8 @@ Suggests: R6, reticulate, rmarkdown, - testthat (>= 3.0.0) + testthat (>= 3.0.0), + withr VignetteBuilder: knitr Config/Needs/website: tidyverse/tidytemplate diff --git a/tests/testthat/test-setup.R b/tests/testthat/test-setup.R index 52e31c3..b146a36 100644 --- a/tests/testthat/test-setup.R +++ b/tests/testthat/test-setup.R @@ -221,3 +221,34 @@ test_that("setup() compiles to a do_setup() state", { yield(1) })) }) + +test_that("setup() supports withr::defer() and withr::local_*() per step", { + # The documented contract: withr teardown registered in a setup() body is + # scoped to the step (set up before each step, restored at its end). + skip_if_not_installed("withr") + + the <- new.env() + the$x <- 0 + seen <- new.env() + + gen <- generator(function() { + setup({ + the$x <- 1 + withr::defer(the$x <- 0) + withr::local_options(coro.setup.flag = "on") + }) + seen$flag <- c(seen$flag, getOption("coro.setup.flag", "off")) + yield(the$x) + seen$flag <- c(seen$flag, getOption("coro.setup.flag", "off")) + yield(the$x) + }) + g <- gen() + + expect_equal(g(), 1) # withr::defer set the$x to 1 + expect_equal(the$x, 0) # ...and restored it at step end + expect_null(getOption("coro.setup.flag")) # withr::local_options restored too + expect_equal(g(), 1) + expect_equal(the$x, 0) + expect_null(getOption("coro.setup.flag")) + expect_equal(seen$flag, c("on", "on")) # option was set during each step +}) From b3001f8bfc7ddd9516f7c548665ad97e86582bf7 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 4 Jun 2026 16:07:40 -0400 Subject: [PATCH 18/31] refactor(setup): extract setup lifecycle into init_setup_runtime() (#68) --- R/generator.R | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/R/generator.R b/R/generator.R index 265116c..e3ab2f4 100644 --- a/R/generator.R +++ b/R/generator.R @@ -353,9 +353,6 @@ new_generator_env <- function(parent, info) { env$exits <- NULL env$exited <- TRUE env$.last_value <- NULL - env$setups <- list() - env$setup_ids <- integer() - env$step_teardowns <- list() with(env, { user <- function(expr) { @@ -369,7 +366,30 @@ new_generator_env <- function(parent, info) { exited <<- FALSE exits <<- env_poke_exits(user_env, NULL) } + }) + + init_setup_runtime(env) + + if (!is_null(info$async_ops)) { + env$then <- info$async_ops$then + env$as_promise <- info$async_ops$as_promise + } + + env +} + +# Installs the `setup()` lifecycle into the generator's runtime `env`: +# registration + per-call-site deduping (`do_setup()`), the capture-and-clear +# harvest of each setup body's teardowns (`run_one_setup()`), re-running the +# registered setups at the start of each step (`run_setups()`), and replaying +# the harvested teardowns at step end (`run_step_teardowns()`). Kept separate so +# `new_generator_env()` stays focused on env construction. +init_setup_runtime <- function(env) { + env$setups <- list() + env$setup_ids <- integer() + env$step_teardowns <- list() + with(env, { run_one_setup <- function(expr) { # Run the setup body in a throwaway closure whose frame is an isolated # child of `user_env`. Capture its `on.exit()`/`withr::defer()` @@ -429,12 +449,7 @@ new_generator_env <- function(parent, info) { } }) - if (!is_null(info$async_ops)) { - env$then <- info$async_ops$then - env$as_promise <- info$async_ops$as_promise - } - - env + invisible(env) } env_bind_arg <- function(env, arg, frame = caller_env()) { From 973ab070d7ab114a9f53144de90eda81bab67693 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 1 Jul 2026 16:36:25 -0400 Subject: [PATCH 19/31] refactor(setup): rename teardown loop variables per review (#68) --- R/generator.R | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/R/generator.R b/R/generator.R index 088c0dd..ce9413b 100644 --- a/R/generator.R +++ b/R/generator.R @@ -421,14 +421,14 @@ init_setup_runtime <- function(env) { run_step_teardowns <- function() { err <- NULL - for (td in rev(step_teardowns)) { - if (is_null(td$exits)) { + for (teardown in rev(step_teardowns)) { + if (is_null(teardown$exits)) { next } - exprs <- if (is_call(td$exits, "{")) as.list(td$exits)[-1] else list(td$exits) - for (ex in exprs) { + exprs <- if (is_call(teardown$exits, "{")) as.list(teardown$exits)[-1] else list(teardown$exits) + for (expr in exprs) { tryCatch( - eval_bare(ex, td$env), + eval_bare(expr, teardown$env), error = function(cnd) if (is_null(err)) err <<- cnd ) } From 7bd640836556162af654d55763d9bc59a6c12ecf Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 1 Jul 2026 16:48:57 -0400 Subject: [PATCH 20/31] feat(setup): error when setup() is used in a loop (#68) Mixing per-step setup registration with iteration has opaque semantics, so `setup()` inside for/while/repeat is now a compile-time error (detected by a recursive AST check that ignores nested coroutine bodies). Document and test the sub-generator delegation workaround for per-iteration setup/teardown. --- R/parser.R | 29 +++++++++++++++++ R/setup.R | 27 ++++++++++++--- R/utils-parser.R | 3 ++ man/setup.Rd | 26 ++++++++++++--- tests/testthat/test-setup.R | 65 ++++++++++++++++++++++++------------- 5 files changed, 117 insertions(+), 33 deletions(-) diff --git a/R/parser.R b/R/parser.R index e215458..951bff9 100644 --- a/R/parser.R +++ b/R/parser.R @@ -1,4 +1,6 @@ walk_states <- function(expr, info) { + check_setup_calls(expr) + continue <- function(counter, last) { # Break if last if (last) 0L else counter() + 1L @@ -24,6 +26,33 @@ walk_states <- function(expr, info) { invisible(exhausted()) }) } + +# `setup()` may not appear inside a loop. Mixing per-step registration with +# iteration (and the branching/yields a loop body may contain) has opaque +# semantics. For per-iteration setup and teardown, factor the loop body into +# its own generator and delegate to it, e.g. `for (x in step(i)) yield(x)`. +# Nested `function` definitions are their own coroutines and are not descended +# into. Setup bodies are user code, not a nested `setup()`, so we stop there too. +check_setup_calls <- function(expr, in_loop = FALSE) { + if (!is_call(expr) || is_call(expr, "function")) { + return(invisible()) + } + if (is_setup_call(expr)) { + if (in_loop) { + abort(c( + "Can't use `setup()` within a loop.", + i = "For per-iteration setup and teardown, move the loop body into its own generator and delegate to it with `for (x in step(i)) yield(x)`." + )) + } + return(invisible()) + } + in_loop <- in_loop || is_call(expr, c("for", "while", "repeat")) + for (arg in as.list(expr)[-1]) { + check_setup_calls(arg, in_loop) + } + invisible() +} + walk_loop_states <- function(body, states, counter, info) { continue <- function(counter, last) { # Go back to state 1 of loop body if last diff --git a/R/setup.R b/R/setup.R index 98f9899..1b135ac 100644 --- a/R/setup.R +++ b/R/setup.R @@ -17,13 +17,30 @@ #' (`the$x <- 1`, `<<-`), but **plain assignments stay local to `expr`** and are #' not visible to the function body. #' - When execution reaches a `setup()` call it is registered and runs for the -#' current step; re-encountering the *same* `setup()` call (e.g. on a later loop -#' iteration) is a no-op. A `setup()` inside an `if`/loop registers only if and -#' when reached. +#' current step. A `setup()` inside an `if` branch registers only if and when +#' reached. #' - Multiple `setup()` calls stack and run in registration order each step; their #' teardowns fire in reverse order. -#' - `setup()` cannot contain `yield()`/`await()` and its result cannot be -#' assigned. +#' - `setup()` cannot be used inside a loop (`for`/`while`/`repeat`), cannot +#' contain `yield()`/`await()`, and its result cannot be assigned. +#' +#' For per-iteration setup and teardown, factor the loop body into its own +#' generator and delegate to it. The sub-generator's `setup()` is scoped to its +#' own steps: +#' +#' ```r +#' step <- generator(function(i) { +#' setup({ +#' # per-iteration setup and teardown +#' }) +#' yield(i) +#' }) +#' gen <- generator(function() { +#' for (i in 1:3) { +#' for (x in step(i)) yield(x) +#' } +#' }) +#' ``` #' #' Like [yield()] and [await()], `setup()` is a syntactic construct recognised by #' the coroutine compiler. Calling it directly (outside a coroutine body) is an diff --git a/R/utils-parser.R b/R/utils-parser.R index 3eb9e23..2a158bc 100644 --- a/R/utils-parser.R +++ b/R/utils-parser.R @@ -39,6 +39,9 @@ is_yield_call <- function(x) { is_await_call <- function(x) { is_call(x, "await", ns = c("", "coro")) } +is_setup_call <- function(x) { + is_call(x, "setup", ns = c("", "coro")) +} # Useful to circumvent notes emitted by the bytecode compiler next_call <- function() call("next") diff --git a/man/setup.Rd b/man/setup.Rd index a553444..28f47c3 100644 --- a/man/setup.Rd +++ b/man/setup.Rd @@ -27,15 +27,31 @@ arguments, locals, and lexical scope, and mutate external state (\code{the$x <- 1}, \verb{<<-}), but \strong{plain assignments stay local to \code{expr}} and are not visible to the function body. \item When execution reaches a \code{setup()} call it is registered and runs for the -current step; re-encountering the \emph{same} \code{setup()} call (e.g. on a later loop -iteration) is a no-op. A \code{setup()} inside an \code{if}/loop registers only if and -when reached. +current step. A \code{setup()} inside an \code{if} branch registers only if and when +reached. \item Multiple \code{setup()} calls stack and run in registration order each step; their teardowns fire in reverse order. -\item \code{setup()} cannot contain \code{yield()}/\code{await()} and its result cannot be -assigned. +\item \code{setup()} cannot be used inside a loop (\code{for}/\code{while}/\code{repeat}), cannot +contain \code{yield()}/\code{await()}, and its result cannot be assigned. } +For per-iteration setup and teardown, factor the loop body into its own +generator and delegate to it. The sub-generator's \code{setup()} is scoped to its +own steps: + +\if{html}{\out{
}}\preformatted{step <- generator(function(i) \{ + setup(\{ + # per-iteration setup and teardown + \}) + yield(i) +\}) +gen <- generator(function() \{ + for (i in 1:3) \{ + for (x in step(i)) yield(x) + \} +\}) +}\if{html}{\out{
}} + Like \code{\link[=yield]{yield()}} and \code{\link[=await]{await()}}, \code{setup()} is a syntactic construct recognised by the coroutine compiler. Calling it directly (outside a coroutine body) is an error. diff --git a/tests/testthat/test-setup.R b/tests/testthat/test-setup.R index b146a36..575d36c 100644 --- a/tests/testthat/test-setup.R +++ b/tests/testthat/test-setup.R @@ -152,36 +152,55 @@ test_that("KNOWN LIMITATION: a setup() after a suspend is not retroactive", { expect_true("setup-registered" %in% log) }) -test_that("KNOWN LIMITATION: setup() in a loop is per-step, not per-iteration", { - runs <- 0L - gen <- generator(function() { - for (i in 1:2) { - setup(runs <<- runs + 1L) - yield(i) # 2 iterations x 2 yields = 4 steps total - yield(i * 10L) - } - }) - g <- gen() - g(); g(); g(); g() - # Runs once per *step* (4), not once per *iteration* (which would be 2): - # registered on the first encounter, re-running at the start of every step; - # the re-encounter of setup() on iteration 2 is a dedup no-op. - expect_equal(runs, 4L) +test_that("setup() within a loop is an error", { + # Mixing per-step registration with iteration has opaque semantics, so it is + # rejected. See the sub-generator workaround in the following test. + expect_error( + generator(function() for (i in 1:2) { setup(NULL); yield(i) })(), + "within a loop" + ) + expect_error( + generator(function() while (TRUE) { setup(NULL); yield(1) })(), + "within a loop" + ) + expect_error( + generator(function() repeat { setup(NULL); yield(1) })(), + "within a loop" + ) + # Also caught when nested in a branch inside the loop. + expect_error( + generator(function() for (i in 1:2) { if (i == 1) setup(NULL); yield(i) })(), + "within a loop" + ) }) -test_that("KNOWN LIMITATION: setup() registration is sticky across branches", { - runs <- 0L +test_that("per-iteration setup/teardown works by delegating to a sub-generator", { + the <- new.env() + the$x <- 0 + seen <- new.env() + + # Factor the per-iteration body into its own generator: its setup() is scoped + # to the sub-generator's steps, giving per-iteration setup/teardown. + step <- generator(function(i) { + setup({ + the$x <- i + on.exit(the$x <- 0, add = TRUE) + }) + seen$during <- c(seen$during, the$x) + yield(i) + }) + gen <- generator(function() { for (i in 1:3) { - if (i == 1) { - setup(runs <<- runs + 1L) + for (x in step(i)) { + yield(x) } - yield(i) } }) - g <- gen() - g(); g(); g() - expect_equal(runs, 3L) + + expect_equal(collect(gen()), list(1L, 2L, 3L)) + expect_equal(seen$during, c(1L, 2L, 3L)) # setup ran with the$x set per iteration + expect_equal(the$x, 0) # ...and restored after each iteration }) test_that("KNOWN LIMITATION: a teardown error disables the generator", { From b0113b91f2bdbf372e6b7396743f0e8df9fc8eee Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 1 Jul 2026 16:52:36 -0400 Subject: [PATCH 21/31] docs(setup): make the loop workaround a concrete runnable example (#68) --- R/setup.R | 40 ++++++++++++++++++++++++---------------- man/setup.Rd | 39 ++++++++++++++++++++++++--------------- 2 files changed, 48 insertions(+), 31 deletions(-) diff --git a/R/setup.R b/R/setup.R index 1b135ac..ade0ec6 100644 --- a/R/setup.R +++ b/R/setup.R @@ -25,22 +25,9 @@ #' contain `yield()`/`await()`, and its result cannot be assigned. #' #' For per-iteration setup and teardown, factor the loop body into its own -#' generator and delegate to it. The sub-generator's `setup()` is scoped to its -#' own steps: -#' -#' ```r -#' step <- generator(function(i) { -#' setup({ -#' # per-iteration setup and teardown -#' }) -#' yield(i) -#' }) -#' gen <- generator(function() { -#' for (i in 1:3) { -#' for (x in step(i)) yield(x) -#' } -#' }) -#' ``` +#' generator and delegate to it with a `for` loop (see examples). Each +#' sub-generator instance has its own `setup()` lifecycle, scoped to that +#' iteration. #' #' Like [yield()] and [await()], `setup()` is a syntactic construct recognised by #' the coroutine compiler. Calling it directly (outside a coroutine body) is an @@ -68,6 +55,27 @@ #' the$x # 0 — restored at the end of the step #' g() # 1 #' the$x # 0 +#' +#' # `setup()` can't be used inside a loop. For per-iteration setup and +#' # teardown, move the loop body into a sub-generator and delegate to it with a +#' # `for` loop; the sub-generator's `setup()` runs afresh for each iteration: +#' the$x <- 0 +#' step <- generator(function(i) { +#' setup({ +#' the$x <- i # set up for this iteration... +#' on.exit(the$x <- 0, add = TRUE) # ...and torn down at the end of it +#' }) +#' yield(the$x) +#' }) +#' +#' gen <- generator(function() { +#' for (i in 1:3) { +#' for (x in step(i)) yield(x) +#' } +#' }) +#' +#' collect(gen()) # 1, 2, 3 — each produced with the$x set for that iteration +#' the$x # 0 — restored after every iteration #' @export setup <- function(expr) { abort("`setup()` can't be called directly or within function arguments.") diff --git a/man/setup.Rd b/man/setup.Rd index 28f47c3..aea2ba1 100644 --- a/man/setup.Rd +++ b/man/setup.Rd @@ -36,21 +36,9 @@ contain \code{yield()}/\code{await()}, and its result cannot be assigned. } For per-iteration setup and teardown, factor the loop body into its own -generator and delegate to it. The sub-generator's \code{setup()} is scoped to its -own steps: - -\if{html}{\out{
}}\preformatted{step <- generator(function(i) \{ - setup(\{ - # per-iteration setup and teardown - \}) - yield(i) -\}) -gen <- generator(function() \{ - for (i in 1:3) \{ - for (x in step(i)) yield(x) - \} -\}) -}\if{html}{\out{
}} +generator and delegate to it with a \code{for} loop (see examples). Each +sub-generator instance has its own \code{setup()} lifecycle, scoped to that +iteration. Like \code{\link[=yield]{yield()}} and \code{\link[=await]{await()}}, \code{setup()} is a syntactic construct recognised by the coroutine compiler. Calling it directly (outside a coroutine body) is an @@ -75,6 +63,27 @@ g() # 1 the$x # 0 — restored at the end of the step g() # 1 the$x # 0 + +# `setup()` can't be used inside a loop. For per-iteration setup and +# teardown, move the loop body into a sub-generator and delegate to it with a +# `for` loop; the sub-generator's `setup()` runs afresh for each iteration: +the$x <- 0 +step <- generator(function(i) { + setup({ + the$x <- i # set up for this iteration... + on.exit(the$x <- 0, add = TRUE) # ...and torn down at the end of it + }) + yield(the$x) +}) + +gen <- generator(function() { + for (i in 1:3) { + for (x in step(i)) yield(x) + } +}) + +collect(gen()) # 1, 2, 3 — each produced with the$x set for that iteration +the$x # 0 — restored after every iteration } \seealso{ \code{\link[=generator]{generator()}}, \code{\link[=async]{async()}}, \code{\link[=yield]{yield()}}, \code{\link[=await]{await()}}. From dd7bb9b4849ce5a32f1281bf14a9e1dd97caea67 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 1 Jul 2026 16:56:50 -0400 Subject: [PATCH 22/31] docs(setup): add second yield to workaround example to show per-step re-run (#68) --- R/setup.R | 14 ++++++++------ man/setup.Rd | 14 ++++++++------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/R/setup.R b/R/setup.R index ade0ec6..adb69cf 100644 --- a/R/setup.R +++ b/R/setup.R @@ -58,14 +58,16 @@ #' #' # `setup()` can't be used inside a loop. For per-iteration setup and #' # teardown, move the loop body into a sub-generator and delegate to it with a -#' # `for` loop; the sub-generator's `setup()` runs afresh for each iteration: +#' # `for` loop; the sub-generator's `setup()` runs afresh for each step: #' the$x <- 0 #' step <- generator(function(i) { #' setup({ -#' the$x <- i # set up for this iteration... -#' on.exit(the$x <- 0, add = TRUE) # ...and torn down at the end of it +#' the$x <- i # set up before every step... +#' on.exit(the$x <- 0, add = TRUE) # ...and torn down at the end of each #' }) -#' yield(the$x) +#' yield(the$x) # the$x is i here +#' yield(the$x) # still i: the$x was reset to 0 at the previous step end, +#' # then setup() re-ran before this step #' }) #' #' gen <- generator(function() { @@ -74,8 +76,8 @@ #' } #' }) #' -#' collect(gen()) # 1, 2, 3 — each produced with the$x set for that iteration -#' the$x # 0 — restored after every iteration +#' collect(gen()) # 1, 1, 2, 2, 3, 3 +#' the$x # 0 — restored after every step #' @export setup <- function(expr) { abort("`setup()` can't be called directly or within function arguments.") diff --git a/man/setup.Rd b/man/setup.Rd index aea2ba1..483b5b8 100644 --- a/man/setup.Rd +++ b/man/setup.Rd @@ -66,14 +66,16 @@ the$x # 0 # `setup()` can't be used inside a loop. For per-iteration setup and # teardown, move the loop body into a sub-generator and delegate to it with a -# `for` loop; the sub-generator's `setup()` runs afresh for each iteration: +# `for` loop; the sub-generator's `setup()` runs afresh for each step: the$x <- 0 step <- generator(function(i) { setup({ - the$x <- i # set up for this iteration... - on.exit(the$x <- 0, add = TRUE) # ...and torn down at the end of it + the$x <- i # set up before every step... + on.exit(the$x <- 0, add = TRUE) # ...and torn down at the end of each }) - yield(the$x) + yield(the$x) # the$x is i here + yield(the$x) # still i: the$x was reset to 0 at the previous step end, + # then setup() re-ran before this step }) gen <- generator(function() { @@ -82,8 +84,8 @@ gen <- generator(function() { } }) -collect(gen()) # 1, 2, 3 — each produced with the$x set for that iteration -the$x # 0 — restored after every iteration +collect(gen()) # 1, 1, 2, 2, 3, 3 +the$x # 0 — restored after every step } \seealso{ \code{\link[=generator]{generator()}}, \code{\link[=async]{async()}}, \code{\link[=yield]{yield()}}, \code{\link[=await]{await()}}. From 61e061629f16ae4d77a476bfe3f4ed56916295ea Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 1 Jul 2026 16:58:40 -0400 Subject: [PATCH 23/31] docs(setup): move loop usage into a dedicated help section (#68) --- R/setup.R | 69 +++++++++++++++++++++++++++++---------------------- man/setup.Rd | 70 ++++++++++++++++++++++++++++++---------------------- 2 files changed, 79 insertions(+), 60 deletions(-) diff --git a/R/setup.R b/R/setup.R index adb69cf..da4d89d 100644 --- a/R/setup.R +++ b/R/setup.R @@ -21,18 +21,50 @@ #' reached. #' - Multiple `setup()` calls stack and run in registration order each step; their #' teardowns fire in reverse order. -#' - `setup()` cannot be used inside a loop (`for`/`while`/`repeat`), cannot -#' contain `yield()`/`await()`, and its result cannot be assigned. -#' -#' For per-iteration setup and teardown, factor the loop body into its own -#' generator and delegate to it with a `for` loop (see examples). Each -#' sub-generator instance has its own `setup()` lifecycle, scoped to that -#' iteration. +#' - `setup()` cannot contain `yield()`/`await()` and its result cannot be +#' assigned. #' #' Like [yield()] and [await()], `setup()` is a syntactic construct recognised by #' the coroutine compiler. Calling it directly (outside a coroutine body) is an #' error. #' +#' @section Using setup() in a loop: +#' +#' `setup()` cannot be used inside a loop (`for`, `while`, or `repeat`); doing so +#' is an error. Per-step registration interacts poorly with iteration: a loop +#' body may branch and yield, so it is ambiguous when the setup should be +#' registered, re-run, and torn down relative to each iteration. +#' +#' When you need per-iteration setup and teardown, factor the loop body into its +#' own generator and delegate to it with a `for` loop. Each sub-generator +#' instance has its own `setup()` lifecycle, scoped to its steps. The second +#' `yield()` below shows the effect: after the first yield the step ends and +#' `the$x` is reset to `0`, then `setup()` re-runs before the next step, so the +#' second yield again sees `i`: +#' +#' ```r +#' the <- new.env() +#' the$x <- 0 +#' +#' step <- generator(function(i) { +#' setup({ +#' the$x <- i # set up before every step... +#' on.exit(the$x <- 0, add = TRUE) # ...and torn down at the end of each +#' }) +#' yield(the$x) # the$x is i here +#' yield(the$x) # still i: reset to 0 at the last step end, then setup() re-ran +#' }) +#' +#' gen <- generator(function() { +#' for (i in 1:3) { +#' for (x in step(i)) yield(x) +#' } +#' }) +#' +#' collect(gen()) # 1, 1, 2, 2, 3, 3 +#' the$x # 0 +#' ``` +#' #' @param expr An expression to run at the start of each step. #' #' @seealso [generator()], [async()], [yield()], [await()]. @@ -55,29 +87,6 @@ #' the$x # 0 — restored at the end of the step #' g() # 1 #' the$x # 0 -#' -#' # `setup()` can't be used inside a loop. For per-iteration setup and -#' # teardown, move the loop body into a sub-generator and delegate to it with a -#' # `for` loop; the sub-generator's `setup()` runs afresh for each step: -#' the$x <- 0 -#' step <- generator(function(i) { -#' setup({ -#' the$x <- i # set up before every step... -#' on.exit(the$x <- 0, add = TRUE) # ...and torn down at the end of each -#' }) -#' yield(the$x) # the$x is i here -#' yield(the$x) # still i: the$x was reset to 0 at the previous step end, -#' # then setup() re-ran before this step -#' }) -#' -#' gen <- generator(function() { -#' for (i in 1:3) { -#' for (x in step(i)) yield(x) -#' } -#' }) -#' -#' collect(gen()) # 1, 1, 2, 2, 3, 3 -#' the$x # 0 — restored after every step #' @export setup <- function(expr) { abort("`setup()` can't be called directly or within function arguments.") diff --git a/man/setup.Rd b/man/setup.Rd index 483b5b8..b33ccf6 100644 --- a/man/setup.Rd +++ b/man/setup.Rd @@ -31,19 +31,52 @@ current step. A \code{setup()} inside an \code{if} branch registers only if and reached. \item Multiple \code{setup()} calls stack and run in registration order each step; their teardowns fire in reverse order. -\item \code{setup()} cannot be used inside a loop (\code{for}/\code{while}/\code{repeat}), cannot -contain \code{yield()}/\code{await()}, and its result cannot be assigned. +\item \code{setup()} cannot contain \code{yield()}/\code{await()} and its result cannot be +assigned. } -For per-iteration setup and teardown, factor the loop body into its own -generator and delegate to it with a \code{for} loop (see examples). Each -sub-generator instance has its own \code{setup()} lifecycle, scoped to that -iteration. - Like \code{\link[=yield]{yield()}} and \code{\link[=await]{await()}}, \code{setup()} is a syntactic construct recognised by the coroutine compiler. Calling it directly (outside a coroutine body) is an error. } +\section{Using setup() in a loop}{ + + +\code{setup()} cannot be used inside a loop (\code{for}, \code{while}, or \code{repeat}); doing so +is an error. Per-step registration interacts poorly with iteration: a loop +body may branch and yield, so it is ambiguous when the setup should be +registered, re-run, and torn down relative to each iteration. + +When you need per-iteration setup and teardown, factor the loop body into its +own generator and delegate to it with a \code{for} loop. Each sub-generator +instance has its own \code{setup()} lifecycle, scoped to its steps. The second +\code{yield()} below shows the effect: after the first yield the step ends and +\code{the$x} is reset to \code{0}, then \code{setup()} re-runs before the next step, so the +second yield again sees \code{i}: + +\if{html}{\out{
}}\preformatted{the <- new.env() +the$x <- 0 + +step <- generator(function(i) \{ + setup(\{ + the$x <- i # set up before every step... + on.exit(the$x <- 0, add = TRUE) # ...and torn down at the end of each + \}) + yield(the$x) # the$x is i here + yield(the$x) # still i: reset to 0 at the last step end, then setup() re-ran +\}) + +gen <- generator(function() \{ + for (i in 1:3) \{ + for (x in step(i)) yield(x) + \} +\}) + +collect(gen()) # 1, 1, 2, 2, 3, 3 +the$x # 0 +}\if{html}{\out{
}} +} + \examples{ the <- new.env() the$x <- 0 @@ -63,29 +96,6 @@ g() # 1 the$x # 0 — restored at the end of the step g() # 1 the$x # 0 - -# `setup()` can't be used inside a loop. For per-iteration setup and -# teardown, move the loop body into a sub-generator and delegate to it with a -# `for` loop; the sub-generator's `setup()` runs afresh for each step: -the$x <- 0 -step <- generator(function(i) { - setup({ - the$x <- i # set up before every step... - on.exit(the$x <- 0, add = TRUE) # ...and torn down at the end of each - }) - yield(the$x) # the$x is i here - yield(the$x) # still i: the$x was reset to 0 at the previous step end, - # then setup() re-ran before this step -}) - -gen <- generator(function() { - for (i in 1:3) { - for (x in step(i)) yield(x) - } -}) - -collect(gen()) # 1, 1, 2, 2, 3, 3 -the$x # 0 — restored after every step } \seealso{ \code{\link[=generator]{generator()}}, \code{\link[=async]{async()}}, \code{\link[=yield]{yield()}}, \code{\link[=await]{await()}}. From df2851e0e181c8d1e43ca71d03e79356c927a4b5 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 1 Jul 2026 17:02:57 -0400 Subject: [PATCH 24/31] docs(setup): use withr::defer() in examples instead of on.exit() (#68) --- R/setup.R | 6 +++--- man/setup.Rd | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/R/setup.R b/R/setup.R index da4d89d..70d5985 100644 --- a/R/setup.R +++ b/R/setup.R @@ -48,8 +48,8 @@ #' #' step <- generator(function(i) { #' setup({ -#' the$x <- i # set up before every step... -#' on.exit(the$x <- 0, add = TRUE) # ...and torn down at the end of each +#' the$x <- i # set up before every step... +#' withr::defer(the$x <- 0) # ...and torn down at the end of each #' }) #' yield(the$x) # the$x is i here #' yield(the$x) # still i: reset to 0 at the last step end, then setup() re-ran @@ -76,7 +76,7 @@ #' setup({ #' old_x <- the$x #' the$x <- 1 -#' on.exit(the$x <- old_x, add = TRUE) +#' withr::defer(the$x <- old_x) #' }) #' yield(the$x) # 1 while the step runs #' yield(the$x) # 1 again: setup re-ran for this step diff --git a/man/setup.Rd b/man/setup.Rd index b33ccf6..073de83 100644 --- a/man/setup.Rd +++ b/man/setup.Rd @@ -59,8 +59,8 @@ the$x <- 0 step <- generator(function(i) \{ setup(\{ - the$x <- i # set up before every step... - on.exit(the$x <- 0, add = TRUE) # ...and torn down at the end of each + the$x <- i # set up before every step... + withr::defer(the$x <- 0) # ...and torn down at the end of each \}) yield(the$x) # the$x is i here yield(the$x) # still i: reset to 0 at the last step end, then setup() re-ran @@ -85,7 +85,7 @@ gen <- generator(function() { setup({ old_x <- the$x the$x <- 1 - on.exit(the$x <- old_x, add = TRUE) + withr::defer(the$x <- old_x) }) yield(the$x) # 1 while the step runs yield(the$x) # 1 again: setup re-ran for this step From ed8819f3ef3ca9517aa86eed62a39ca3328a2926 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 1 Jul 2026 17:22:26 -0400 Subject: [PATCH 25/31] docs(setup): describe setup scope as a child environment, not "isolated" (#68) --- R/setup.R | 8 +++++--- man/setup.Rd | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/R/setup.R b/R/setup.R index 70d5985..02747d7 100644 --- a/R/setup.R +++ b/R/setup.R @@ -12,10 +12,12 @@ #' top-level `on.exit()` which only fires once when the whole function exits. #' #' @details -#' - `expr` runs in an isolated environment: it can read the function's -#' arguments, locals, and lexical scope, and mutate external state +#' - `expr` runs in a child of the coroutine's environment: it can read the +#' function's arguments, locals, and lexical scope, and mutate external state #' (`the$x <- 1`, `<<-`), but **plain assignments stay local to `expr`** and are -#' not visible to the function body. +#' not visible to the function body. This matters because `setup()` re-runs +#' before every step: were plain assignments visible, each step would reset the +#' body's state. #' - When execution reaches a `setup()` call it is registered and runs for the #' current step. A `setup()` inside an `if` branch registers only if and when #' reached. diff --git a/man/setup.Rd b/man/setup.Rd index 073de83..57b9695 100644 --- a/man/setup.Rd +++ b/man/setup.Rd @@ -22,10 +22,12 @@ top-level \code{on.exit()} which only fires once when the whole function exits. } \details{ \itemize{ -\item \code{expr} runs in an isolated environment: it can read the function's -arguments, locals, and lexical scope, and mutate external state +\item \code{expr} runs in a child of the coroutine's environment: it can read the +function's arguments, locals, and lexical scope, and mutate external state (\code{the$x <- 1}, \verb{<<-}), but \strong{plain assignments stay local to \code{expr}} and are -not visible to the function body. +not visible to the function body. This matters because \code{setup()} re-runs +before every step: were plain assignments visible, each step would reset the +body's state. \item When execution reaches a \code{setup()} call it is registered and runs for the current step. A \code{setup()} inside an \code{if} branch registers only if and when reached. From 2f9b59763f6089ba9ae0cf0ca0679d8ca5c238ea Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Wed, 1 Jul 2026 17:23:40 -0400 Subject: [PATCH 26/31] docs(setup): add 'Assignments and scope' help section (#68) --- R/setup.R | 35 ++++++++++++++++++++++++++++++++--- man/setup.Rd | 36 +++++++++++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/R/setup.R b/R/setup.R index 02747d7..0fa0251 100644 --- a/R/setup.R +++ b/R/setup.R @@ -15,9 +15,7 @@ #' - `expr` runs in a child of the coroutine's environment: it can read the #' function's arguments, locals, and lexical scope, and mutate external state #' (`the$x <- 1`, `<<-`), but **plain assignments stay local to `expr`** and are -#' not visible to the function body. This matters because `setup()` re-runs -#' before every step: were plain assignments visible, each step would reset the -#' body's state. +#' not visible to the function body (see the "Assignments and scope" section). #' - When execution reaches a `setup()` call it is registered and runs for the #' current step. A `setup()` inside an `if` branch registers only if and when #' reached. @@ -30,6 +28,37 @@ #' the coroutine compiler. Calling it directly (outside a coroutine body) is an #' error. #' +#' @section Assignments and scope: +#' +#' `setup()` runs its body in a child of the coroutine's environment. It can read +#' the function's variables and lexical scope, but a plain assignment (`x <- 1`) +#' creates a *local* binding that the function body does not see. +#' +#' This is deliberate. Unlike [on.exit()] and `withr::defer()`, which register +#' once and run at exit, `setup()` re-runs its body before *every* step. If plain +#' assignments were visible to the body, each step would silently overwrite +#' whatever the body had accumulated. In the generator below, `setup()`'s local +#' `n` does not touch the body's `n`, so it yields the expected `5` then `6`; were +#' the assignment visible, the second step would reset `n` to `100` and yield +#' `101`: +#' +#' ```r +#' generator(function() { +#' setup({ n <- 100 }) # local to setup; does not touch the body's `n` +#' n <- 5 +#' yield(n) # 5 +#' n <- n + 1 +#' yield(n) # 6 +#' }) +#' ``` +#' +#' To change state that the body (or the outside world) can see, mutate an +#' existing object instead of creating a local binding: assign into an +#' environment (`the$x <- 1`) or use `<<-` to update an enclosing binding, and +#' pair the change with `withr::defer()`/[on.exit()] to restore it at the end of +#' each step. If you need a value computed per step *and* visible to the body, +#' use the sub-generator pattern shown in "Using setup() in a loop". +#' #' @section Using setup() in a loop: #' #' `setup()` cannot be used inside a loop (`for`, `while`, or `repeat`); doing so diff --git a/man/setup.Rd b/man/setup.Rd index 57b9695..358eea2 100644 --- a/man/setup.Rd +++ b/man/setup.Rd @@ -25,9 +25,7 @@ top-level \code{on.exit()} which only fires once when the whole function exits. \item \code{expr} runs in a child of the coroutine's environment: it can read the function's arguments, locals, and lexical scope, and mutate external state (\code{the$x <- 1}, \verb{<<-}), but \strong{plain assignments stay local to \code{expr}} and are -not visible to the function body. This matters because \code{setup()} re-runs -before every step: were plain assignments visible, each step would reset the -body's state. +not visible to the function body (see the "Assignments and scope" section). \item When execution reaches a \code{setup()} call it is registered and runs for the current step. A \code{setup()} inside an \code{if} branch registers only if and when reached. @@ -41,6 +39,38 @@ Like \code{\link[=yield]{yield()}} and \code{\link[=await]{await()}}, \code{setu the coroutine compiler. Calling it directly (outside a coroutine body) is an error. } +\section{Assignments and scope}{ + + +\code{setup()} runs its body in a child of the coroutine's environment. It can read +the function's variables and lexical scope, but a plain assignment (\code{x <- 1}) +creates a \emph{local} binding that the function body does not see. + +This is deliberate. Unlike \code{\link[=on.exit]{on.exit()}} and \code{withr::defer()}, which register +once and run at exit, \code{setup()} re-runs its body before \emph{every} step. If plain +assignments were visible to the body, each step would silently overwrite +whatever the body had accumulated. In the generator below, \code{setup()}'s local +\code{n} does not touch the body's \code{n}, so it yields the expected \code{5} then \code{6}; were +the assignment visible, the second step would reset \code{n} to \code{100} and yield +\code{101}: + +\if{html}{\out{
}}\preformatted{generator(function() \{ + setup(\{ n <- 100 \}) # local to setup; does not touch the body's `n` + n <- 5 + yield(n) # 5 + n <- n + 1 + yield(n) # 6 +\}) +}\if{html}{\out{
}} + +To change state that the body (or the outside world) can see, mutate an +existing object instead of creating a local binding: assign into an +environment (\code{the$x <- 1}) or use \verb{<<-} to update an enclosing binding, and +pair the change with \code{withr::defer()}/\code{\link[=on.exit]{on.exit()}} to restore it at the end of +each step. If you need a value computed per step \emph{and} visible to the body, +use the sub-generator pattern shown in "Using setup() in a loop". +} + \section{Using setup() in a loop}{ From b7e122b2ae56603c54c3946a78b0ed1a4d8333f2 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 2 Jul 2026 09:02:18 -0400 Subject: [PATCH 27/31] docs(setup): add withr::local_options() per-step hygiene example (#68) --- R/setup.R | 16 ++++++++++++++++ man/setup.Rd | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/R/setup.R b/R/setup.R index 0fa0251..3005427 100644 --- a/R/setup.R +++ b/R/setup.R @@ -118,6 +118,22 @@ #' the$x # 0 — restored at the end of the step #' g() # 1 #' the$x # 0 +#' +#' # `setup()` is handy for per-step hygiene: global state changed with a withr +#' # helper is restored at the end of each step, so it never leaks across steps +#' # or back to the caller. Here an option is scoped to each step: +#' gen <- generator(function() { +#' setup(withr::local_options(coro.example = "step")) +#' yield(getOption("coro.example", "unset")) +#' yield(getOption("coro.example", "unset")) +#' }) +#' +#' g <- gen() +#' getOption("coro.example", "unset") # "unset" +#' g() # "step" — option set for this step +#' getOption("coro.example", "unset") # "unset" — restored at the step end +#' g() # "step" +#' getOption("coro.example", "unset") # "unset" #' @export setup <- function(expr) { abort("`setup()` can't be called directly or within function arguments.") diff --git a/man/setup.Rd b/man/setup.Rd index 358eea2..fb806a2 100644 --- a/man/setup.Rd +++ b/man/setup.Rd @@ -128,6 +128,22 @@ g() # 1 the$x # 0 — restored at the end of the step g() # 1 the$x # 0 + +# `setup()` is handy for per-step hygiene: global state changed with a withr +# helper is restored at the end of each step, so it never leaks across steps +# or back to the caller. Here an option is scoped to each step: +gen <- generator(function() { + setup(withr::local_options(coro.example = "step")) + yield(getOption("coro.example", "unset")) + yield(getOption("coro.example", "unset")) +}) + +g <- gen() +getOption("coro.example", "unset") # "unset" +g() # "step" — option set for this step +getOption("coro.example", "unset") # "unset" — restored at the step end +g() # "step" +getOption("coro.example", "unset") # "unset" } \seealso{ \code{\link[=generator]{generator()}}, \code{\link[=async]{async()}}, \code{\link[=yield]{yield()}}, \code{\link[=await]{await()}}. From a6f269e1cd0cfafdf32555dc0a27377ae7e6ae82 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 2 Jul 2026 09:21:41 -0400 Subject: [PATCH 28/31] docs(setup): fix and simplify loop-section example (#68) --- R/setup.R | 32 +++++++++++++++++++------------- man/setup.Rd | 32 +++++++++++++++++++------------- 2 files changed, 38 insertions(+), 26 deletions(-) diff --git a/R/setup.R b/R/setup.R index 3005427..f31313a 100644 --- a/R/setup.R +++ b/R/setup.R @@ -44,11 +44,14 @@ #' #' ```r #' generator(function() { -#' setup({ n <- 100 }) # local to setup; does not touch the body's `n` #' n <- 5 -#' yield(n) # 5 +#' setup({ +#' # local to setup; does NOT set the generator's `n` +#' n <- 100 +#' }) +#' yield(n) # yields `5` #' n <- n + 1 -#' yield(n) # 6 +#' yield(n) # yields `6` #' }) #' ``` #' @@ -56,10 +59,12 @@ #' existing object instead of creating a local binding: assign into an #' environment (`the$x <- 1`) or use `<<-` to update an enclosing binding, and #' pair the change with `withr::defer()`/[on.exit()] to restore it at the end of -#' each step. If you need a value computed per step *and* visible to the body, -#' use the sub-generator pattern shown in "Using setup() in a loop". +#' each step. (See "Examples" section) +#' +#' If you need a value computed per step *and* visible to the body, use the +#' sub-generator pattern shown in "Using `setup()` in a loop". #' -#' @section Using setup() in a loop: +#' @section Using `setup()` in a loop: #' #' `setup()` cannot be used inside a loop (`for`, `while`, or `repeat`); doing so #' is an error. Per-step registration interacts poorly with iteration: a loop @@ -68,10 +73,11 @@ #' #' When you need per-iteration setup and teardown, factor the loop body into its #' own generator and delegate to it with a `for` loop. Each sub-generator -#' instance has its own `setup()` lifecycle, scoped to its steps. The second -#' `yield()` below shows the effect: after the first yield the step ends and -#' `the$x` is reset to `0`, then `setup()` re-runs before the next step, so the -#' second yield again sees `i`: +#' instance has its own `setup()` lifecycle, scoped to its own steps, so its +#' teardown fires at every step end and never leaks to the outer generator. The +#' second `yield()` below shows the re-run: after the first yield `the$x` is +#' restored to `0`, then `setup()` re-runs before the next step, so the second +#' yield again sees `i`: #' #' ```r #' the <- new.env() @@ -82,8 +88,8 @@ #' the$x <- i # set up before every step... #' withr::defer(the$x <- 0) # ...and torn down at the end of each #' }) -#' yield(the$x) # the$x is i here -#' yield(the$x) # still i: reset to 0 at the last step end, then setup() re-ran +#' yield(the$x) # `i` +#' yield(the$x) # still `i`: restored to 0 at the last step end, then setup() re-ran #' }) #' #' gen <- generator(function() { @@ -93,7 +99,7 @@ #' }) #' #' collect(gen()) # 1, 1, 2, 2, 3, 3 -#' the$x # 0 +#' the$x # 0 — always restored between steps #' ``` #' #' @param expr An expression to run at the start of each step. diff --git a/man/setup.Rd b/man/setup.Rd index fb806a2..6f7a340 100644 --- a/man/setup.Rd +++ b/man/setup.Rd @@ -55,11 +55,14 @@ the assignment visible, the second step would reset \code{n} to \code{100} and y \code{101}: \if{html}{\out{
}}\preformatted{generator(function() \{ - setup(\{ n <- 100 \}) # local to setup; does not touch the body's `n` n <- 5 - yield(n) # 5 + setup(\{ + # local to setup; does NOT set the generator's `n` + n <- 100 + \}) + yield(n) # yields `5` n <- n + 1 - yield(n) # 6 + yield(n) # yields `6` \}) }\if{html}{\out{
}} @@ -67,11 +70,13 @@ To change state that the body (or the outside world) can see, mutate an existing object instead of creating a local binding: assign into an environment (\code{the$x <- 1}) or use \verb{<<-} to update an enclosing binding, and pair the change with \code{withr::defer()}/\code{\link[=on.exit]{on.exit()}} to restore it at the end of -each step. If you need a value computed per step \emph{and} visible to the body, -use the sub-generator pattern shown in "Using setup() in a loop". +each step. (See "Examples" section) + +If you need a value computed per step \emph{and} visible to the body, use the +sub-generator pattern shown in "Using \code{setup()} in a loop". } -\section{Using setup() in a loop}{ +\section{Using \code{setup()} in a loop}{ \code{setup()} cannot be used inside a loop (\code{for}, \code{while}, or \code{repeat}); doing so @@ -81,10 +86,11 @@ registered, re-run, and torn down relative to each iteration. When you need per-iteration setup and teardown, factor the loop body into its own generator and delegate to it with a \code{for} loop. Each sub-generator -instance has its own \code{setup()} lifecycle, scoped to its steps. The second -\code{yield()} below shows the effect: after the first yield the step ends and -\code{the$x} is reset to \code{0}, then \code{setup()} re-runs before the next step, so the -second yield again sees \code{i}: +instance has its own \code{setup()} lifecycle, scoped to its own steps, so its +teardown fires at every step end and never leaks to the outer generator. The +second \code{yield()} below shows the re-run: after the first yield \code{the$x} is +restored to \code{0}, then \code{setup()} re-runs before the next step, so the second +yield again sees \code{i}: \if{html}{\out{
}}\preformatted{the <- new.env() the$x <- 0 @@ -94,8 +100,8 @@ step <- generator(function(i) \{ the$x <- i # set up before every step... withr::defer(the$x <- 0) # ...and torn down at the end of each \}) - yield(the$x) # the$x is i here - yield(the$x) # still i: reset to 0 at the last step end, then setup() re-ran + yield(the$x) # `i` + yield(the$x) # still `i`: restored to 0 at the last step end, then setup() re-ran \}) gen <- generator(function() \{ @@ -105,7 +111,7 @@ gen <- generator(function() \{ \}) collect(gen()) # 1, 1, 2, 2, 3, 3 -the$x # 0 +the$x # 0 — always restored between steps }\if{html}{\out{
}} } From c413ce6f420a6fce01f8777be15df317d8d37ef4 Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 2 Jul 2026 10:18:29 -0400 Subject: [PATCH 29/31] docs(setup): expand help with lifecycle, scope, and loop sections (#68) - Symmetric @description (entered/resumed vs suspends/finishes) with the yield/await + exit enumeration. - New "When setup and teardown run" section with a step-by-step timeline contrasting a regular defer, a setup defer, and a setup assignment. - "Assignments and scope" explains why plain assignments are local (setup re-runs each step) with a worked example. - "Using setup() in a loop" shows the naive error and the sub-generator workaround; rename the sub-generator to generate_x to avoid clashing with the "step" concept. --- R/setup.R | 115 +++++++++++++++++++++++++++++++++++++++++--------- man/setup.Rd | 116 ++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 191 insertions(+), 40 deletions(-) diff --git a/R/setup.R b/R/setup.R index f31313a..73033cf 100644 --- a/R/setup.R +++ b/R/setup.R @@ -2,11 +2,10 @@ #' #' @description #' `setup()` registers an expression that runs at the start of **every** step of -#' a [generator()] or [async()] function: the initial entry and every resumption -#' after a [yield()] or [await()]. Any `withr::defer()`, `withr::local_*()`, or -#' [on.exit()] registered while `expr` runs is torn down at the **end of that -#' step** (the next `yield`/`await`, a `return`, normal completion, an error, or -#' early close). +#' a [generator()]/[async()] function — each time it is **entered or resumed** — +#' and tears down anything it registers (`withr::defer()`, `withr::local_*()`, or +#' [on.exit()]) at the **end of that step**, when the coroutine **suspends** +#' (`yield()`/`await()`) or **finishes** (`return`, completion, error, or close). #' #' This gives setup/teardown parity for each step of a coroutine, unlike a #' top-level `on.exit()` which only fires once when the whole function exits. @@ -28,6 +27,53 @@ #' the coroutine compiler. Calling it directly (outside a coroutine body) is an #' error. #' +#' @section When setup and teardown run: +#' +#' It helps to compare `setup()` with a regular `withr::defer()`/[on.exit()] over +#' a multi-step run: +#' +#' * A regular `withr::defer()`/[on.exit()] in the function body registers +#' **once** and runs **once**, when the whole coroutine finishes. +#' * A `setup()` body — including any assignments in it — runs at the **start of +#' every step**. +#' * A `withr::defer()`/[on.exit()] registered inside `setup()` runs at the **end +#' of every step**. +#' +#' The log below traces a generator with two `yield()`s (so three steps): +#' +#' ```r +#' log <- character() +#' +#' gen <- generator(function() { +#' log <<- c(log, "beginning") +#' withr::defer(log <<- c(log, "end (once, at function exit)")) +#' setup({ +#' x <- "local" # an assignment here runs at each step start (local to setup) +#' log <<- c(log, " setup body (start of every step)") +#' withr::defer(log <<- c(log, " setup defer (end of every step)")) +#' }) +#' log <<- c(log, " body 1") +#' yield("a") +#' log <<- c(log, " body 2") +#' yield("b") +#' log <<- c(log, " body 3") +#' }) +#' +#' invisible(collect(gen())) +#' writeLines(log) +#' #> beginning +#' #> setup body (start of every step) +#' #> body 1 +#' #> setup defer (end of every step) +#' #> setup body (start of every step) +#' #> body 2 +#' #> setup defer (end of every step) +#' #> setup body (start of every step) +#' #> body 3 +#' #> setup defer (end of every step) +#' #> end (once, at function exit) +#' ``` +#' #' @section Assignments and scope: #' #' `setup()` runs its body in a child of the coroutine's environment. It can read @@ -68,38 +114,67 @@ #' #' `setup()` cannot be used inside a loop (`for`, `while`, or `repeat`); doing so #' is an error. Per-step registration interacts poorly with iteration: a loop -#' body may branch and yield, so it is ambiguous when the setup should be +#' body may itself branch and yield, so it is ambiguous when the setup should be #' registered, re-run, and torn down relative to each iteration. #' -#' When you need per-iteration setup and teardown, factor the loop body into its -#' own generator and delegate to it with a `for` loop. Each sub-generator -#' instance has its own `setup()` lifecycle, scoped to its own steps, so its -#' teardown fires at every step end and never leaks to the outer generator. The -#' second `yield()` below shows the re-run: after the first yield `the$x` is -#' restored to `0`, then `setup()` re-runs before the next step, so the second -#' yield again sees `i`: +#' ```r +#' generator(function() { +#' for (i in 1:3) { +#' # Error! Can't use `setup()` within a loop +#' setup({ +#' the$x <- i +#' withr::defer(the$x <- 0) +#' }) +#' yield(the$x) +#' } +#' }) +#' ``` +#' +#' When you need setup and teardown *per iteration*, move the loop body into its +#' own generator and drive it from the outer generator with `for (x in generate_x(i))`. +#' The key idea is that a generator's `setup()` is scoped to *that generator's +#' own steps*: it re-runs at the start of each step and its teardown fires at the +#' end of each step, independently of whatever is consuming it. +#' +#' Follow `the$x` through the example below. It is a shared variable, yet the +#' mutation inside `generate_x(i)` is never observable from the outer generator: +#' +#' * **Inside `generate_x(i)`.** Each time `generate_x(i)` resumes it re-runs its setup +#' (`the$x <- i`); each time it suspends at a `yield()` it runs its teardown +#' (`the$x <- 0`). So at every `yield()` *inside* `generate_x(i)`, `the$x` equals `i`. +#' +#' * **Outside `generate_x(i)`.** `for (x in generate_x(i))` pulls one value per iteration. +#' Receiving a value means `generate_x(i)` has just suspended, so its teardown has +#' *already run*. By the time the outer loop body executes, `the$x` is back to +#' `0` — which is what `stopifnot(the$x == 0)` asserts. Nothing `generate_x(i)` did to +#' `the$x` leaks out to the outer generator or its caller. #' #' ```r #' the <- new.env() #' the$x <- 0 #' -#' step <- generator(function(i) { +#' # Per-iteration setup/teardown lives in its own generator: +#' generate_x <- generator(function(i) { #' setup({ -#' the$x <- i # set up before every step... -#' withr::defer(the$x <- 0) # ...and torn down at the end of each +#' the$x <- i # runs at the start of each of generate_x(i)'s steps +#' withr::defer(the$x <- 0) # runs at the end of each of generate_x(i)'s steps #' }) -#' yield(the$x) # `i` -#' yield(the$x) # still `i`: restored to 0 at the last step end, then setup() re-ran +#' yield(the$x) # the$x == i +#' yield(the$x) # the$x == i again (setup re-ran for this step) #' }) #' +#' # The outer generator delegates to generate_x(i) and re-yields its values: #' gen <- generator(function() { #' for (i in 1:3) { -#' for (x in step(i)) yield(x) +#' for (x in generate_x(i)) { +#' stopifnot(the$x == 0) # 0 out here: generate_x(i)'s teardown ran when it suspended +#' yield(x) +#' } #' } #' }) #' #' collect(gen()) # 1, 1, 2, 2, 3, 3 -#' the$x # 0 — always restored between steps +#' the$x # 0 #' ``` #' #' @param expr An expression to run at the start of each step. diff --git a/man/setup.Rd b/man/setup.Rd index 6f7a340..68d3cc8 100644 --- a/man/setup.Rd +++ b/man/setup.Rd @@ -11,11 +11,10 @@ setup(expr) } \description{ \code{setup()} registers an expression that runs at the start of \strong{every} step of -a \code{\link[=generator]{generator()}} or \code{\link[=async]{async()}} function: the initial entry and every resumption -after a \code{\link[=yield]{yield()}} or \code{\link[=await]{await()}}. Any \code{withr::defer()}, \verb{withr::local_*()}, or -\code{\link[=on.exit]{on.exit()}} registered while \code{expr} runs is torn down at the \strong{end of that -step} (the next \code{yield}/\code{await}, a \code{return}, normal completion, an error, or -early close). +a \code{\link[=generator]{generator()}}/\code{\link[=async]{async()}} function — each time it is \strong{entered or resumed} — +and tears down anything it registers (\code{withr::defer()}, \verb{withr::local_*()}, or +\code{\link[=on.exit]{on.exit()}}) at the \strong{end of that step}, when the coroutine \strong{suspends} +(\code{yield()}/\code{await()}) or \strong{finishes} (\code{return}, completion, error, or close). This gives setup/teardown parity for each step of a coroutine, unlike a top-level \code{on.exit()} which only fires once when the whole function exits. @@ -39,6 +38,55 @@ Like \code{\link[=yield]{yield()}} and \code{\link[=await]{await()}}, \code{setu the coroutine compiler. Calling it directly (outside a coroutine body) is an error. } +\section{When setup and teardown run}{ + + +It helps to compare \code{setup()} with a regular \code{withr::defer()}/\code{\link[=on.exit]{on.exit()}} over +a multi-step run: +\itemize{ +\item A regular \code{withr::defer()}/\code{\link[=on.exit]{on.exit()}} in the function body registers +\strong{once} and runs \strong{once}, when the whole coroutine finishes. +\item A \code{setup()} body — including any assignments in it — runs at the \strong{start of +every step}. +\item A \code{withr::defer()}/\code{\link[=on.exit]{on.exit()}} registered inside \code{setup()} runs at the \strong{end +of every step}. +} + +The log below traces a generator with two \code{yield()}s (so three steps): + +\if{html}{\out{
}}\preformatted{log <- character() + +gen <- generator(function() \{ + log <<- c(log, "beginning") + withr::defer(log <<- c(log, "end (once, at function exit)")) + setup(\{ + x <- "local" # an assignment here runs at each step start (local to setup) + log <<- c(log, " setup body (start of every step)") + withr::defer(log <<- c(log, " setup defer (end of every step)")) + \}) + log <<- c(log, " body 1") + yield("a") + log <<- c(log, " body 2") + yield("b") + log <<- c(log, " body 3") +\}) + +invisible(collect(gen())) +writeLines(log) +#> beginning +#> setup body (start of every step) +#> body 1 +#> setup defer (end of every step) +#> setup body (start of every step) +#> body 2 +#> setup defer (end of every step) +#> setup body (start of every step) +#> body 3 +#> setup defer (end of every step) +#> end (once, at function exit) +}\if{html}{\out{
}} +} + \section{Assignments and scope}{ @@ -81,37 +129,65 @@ sub-generator pattern shown in "Using \code{setup()} in a loop". \code{setup()} cannot be used inside a loop (\code{for}, \code{while}, or \code{repeat}); doing so is an error. Per-step registration interacts poorly with iteration: a loop -body may branch and yield, so it is ambiguous when the setup should be +body may itself branch and yield, so it is ambiguous when the setup should be registered, re-run, and torn down relative to each iteration. -When you need per-iteration setup and teardown, factor the loop body into its -own generator and delegate to it with a \code{for} loop. Each sub-generator -instance has its own \code{setup()} lifecycle, scoped to its own steps, so its -teardown fires at every step end and never leaks to the outer generator. The -second \code{yield()} below shows the re-run: after the first yield \code{the$x} is -restored to \code{0}, then \code{setup()} re-runs before the next step, so the second -yield again sees \code{i}: +\if{html}{\out{
}}\preformatted{generator(function() \{ + for (i in 1:3) \{ + # Error! Can't use `setup()` within a loop + setup(\{ + the$x <- i + withr::defer(the$x <- 0) + \}) + yield(the$x) + \} +\}) +}\if{html}{\out{
}} + +When you need setup and teardown \emph{per iteration}, move the loop body into its +own generator and drive it from the outer generator with \verb{for (x in generate_x(i))}. +The key idea is that a generator's \code{setup()} is scoped to \emph{that generator's +own steps}: it re-runs at the start of each step and its teardown fires at the +end of each step, independently of whatever is consuming it. + +Follow \code{the$x} through the example below. It is a shared variable, yet the +mutation inside \code{generate_x(i)} is never observable from the outer generator: +\itemize{ +\item \strong{Inside \code{generate_x(i)}.} Each time \code{generate_x(i)} resumes it re-runs its setup +(\code{the$x <- i}); each time it suspends at a \code{yield()} it runs its teardown +(\code{the$x <- 0}). So at every \code{yield()} \emph{inside} \code{generate_x(i)}, \code{the$x} equals \code{i}. +\item \strong{Outside \code{generate_x(i)}.} \verb{for (x in generate_x(i))} pulls one value per iteration. +Receiving a value means \code{generate_x(i)} has just suspended, so its teardown has +\emph{already run}. By the time the outer loop body executes, \code{the$x} is back to +\code{0} — which is what \code{stopifnot(the$x == 0)} asserts. Nothing \code{generate_x(i)} did to +\code{the$x} leaks out to the outer generator or its caller. +} \if{html}{\out{
}}\preformatted{the <- new.env() the$x <- 0 -step <- generator(function(i) \{ +# Per-iteration setup/teardown lives in its own generator: +generate_x <- generator(function(i) \{ setup(\{ - the$x <- i # set up before every step... - withr::defer(the$x <- 0) # ...and torn down at the end of each + the$x <- i # runs at the start of each of generate_x(i)'s steps + withr::defer(the$x <- 0) # runs at the end of each of generate_x(i)'s steps \}) - yield(the$x) # `i` - yield(the$x) # still `i`: restored to 0 at the last step end, then setup() re-ran + yield(the$x) # the$x == i + yield(the$x) # the$x == i again (setup re-ran for this step) \}) +# The outer generator delegates to generate_x(i) and re-yields its values: gen <- generator(function() \{ for (i in 1:3) \{ - for (x in step(i)) yield(x) + for (x in generate_x(i)) \{ + stopifnot(the$x == 0) # 0 out here: generate_x(i)'s teardown ran when it suspended + yield(x) + \} \} \}) collect(gen()) # 1, 1, 2, 2, 3, 3 -the$x # 0 — always restored between steps +the$x # 0 }\if{html}{\out{
}} } From 4ef2f540de94fae2fcf91e2f99354a475ef0bc1b Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 2 Jul 2026 10:34:34 -0400 Subject: [PATCH 30/31] test(setup): reframe expected-behavior tests, drop 'known limitation' framing (#68) --- tests/testthat/test-setup.R | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/tests/testthat/test-setup.R b/tests/testthat/test-setup.R index 575d36c..43e7b15 100644 --- a/tests/testthat/test-setup.R +++ b/tests/testthat/test-setup.R @@ -8,6 +8,7 @@ test_that("setup() can't be called directly or within function arguments", { expect_error(f()(), "within function arguments") }) + test_that("setup() runs before every step; teardown fires at each step end", { the <- new.env() the$x <- 0 @@ -35,6 +36,7 @@ test_that("setup() runs before every step; teardown fires at each step end", { expect_equal(log, c("before1:9", "before2:9")) }) + test_that("setup() teardown is restored around await() (issue #68 reprex)", { skip_on_cran() the <- new.env() @@ -60,6 +62,7 @@ test_that("setup() teardown is restored around await() (issue #68 reprex)", { expect_equal(the$x, 0) }) + test_that("multiple setup() calls stack; teardowns fire in reverse order", { log <- character() @@ -75,6 +78,7 @@ test_that("multiple setup() calls stack; teardowns fire in reverse order", { expect_equal(log, c("body", "teardown-B", "teardown-A")) }) + test_that("a failing teardown does not block other teardowns; first error re-raised", { log <- character() @@ -92,6 +96,7 @@ test_that("a failing teardown does not block other teardowns; first error re-rai expect_equal(log, c("B", "A-1")) }) + test_that("setup() rejects suspension and assignment when the coroutine is compiled", { # The state machine is compiled lazily on the first instance call, so these # errors surface at `f()` (the trailing `()`), not at factory definition. @@ -113,6 +118,7 @@ test_that("setup() rejects suspension and assignment when the coroutine is compi ) }) + test_that("setup() allows a nested coroutine in its body", { gen <- generator(function() { setup({ @@ -123,11 +129,8 @@ test_that("setup() allows a nested coroutine in its body", { expect_equal(gen()(), "ok") }) -# Known shortcomings ---- -# These tests document *deliberate* limitations / surprising-but-correct -# behaviors of setup(). They assert the current behavior on purpose. -test_that("KNOWN LIMITATION: plain assignments in setup() are not visible to the body", { +test_that("setup() runs in a child environment", { gen <- generator(function() { setup({ y <- 99 @@ -137,7 +140,8 @@ test_that("KNOWN LIMITATION: plain assignments in setup() are not visible to the expect_false(gen()()) }) -test_that("KNOWN LIMITATION: a setup() after a suspend is not retroactive", { + +test_that("setup() after a suspend is not retroactive", { log <- character() gen <- generator(function() { log <<- c(log, "step1") @@ -146,12 +150,14 @@ test_that("KNOWN LIMITATION: a setup() after a suspend is not retroactive", { yield(2) }) g <- gen() + expect_equal(log, character()) g() - expect_false("setup-registered" %in% log) + expect_equal(log, c("step1")) g() - expect_true("setup-registered" %in% log) + expect_equal(log, c("step1", "setup-registered")) }) + test_that("setup() within a loop is an error", { # Mixing per-step registration with iteration has opaque semantics, so it is # rejected. See the sub-generator workaround in the following test. @@ -174,7 +180,8 @@ test_that("setup() within a loop is an error", { ) }) -test_that("per-iteration setup/teardown works by delegating to a sub-generator", { + +test_that("per-iteration setup/teardown works when delegating to a sub-generator", { the <- new.env() the$x <- 0 seen <- new.env() @@ -203,7 +210,8 @@ test_that("per-iteration setup/teardown works by delegating to a sub-generator", expect_equal(the$x, 0) # ...and restored after each iteration }) -test_that("KNOWN LIMITATION: a teardown error disables the generator", { + +test_that("a teardown error within setup() disables the generator", { gen <- generator(function() { setup(on.exit(stop("teardown boom"), add = TRUE)) yield(1) @@ -214,8 +222,10 @@ test_that("KNOWN LIMITATION: a teardown error disables the generator", { expect_error(g(), "disabled") }) -test_that("KNOWN LIMITATION: an abandoned async promise leaves no final step", { + +test_that("an abandoned async promise leaves no final step", { skip_on_cran() + the <- new.env() the$x <- 0 f <- async(function() { @@ -231,6 +241,7 @@ test_that("KNOWN LIMITATION: an abandoned async promise leaves no final step", { expect_equal(the$x, 0) }) + test_that("setup() compiles to a do_setup() state", { expect_snapshot0(generator_body(function() { setup({ @@ -241,6 +252,7 @@ test_that("setup() compiles to a do_setup() state", { })) }) + test_that("setup() supports withr::defer() and withr::local_*() per step", { # The documented contract: withr teardown registered in a setup() body is # scoped to the step (set up before each step, restored at its end). @@ -263,7 +275,7 @@ test_that("setup() supports withr::defer() and withr::local_*() per step", { }) g <- gen() - expect_equal(g(), 1) # withr::defer set the$x to 1 + expect_equal(g(), 1) # withr::defer set the$x to 1 expect_equal(the$x, 0) # ...and restored it at step end expect_null(getOption("coro.setup.flag")) # withr::local_options restored too expect_equal(g(), 1) From 5881654e89c19b178da89ab3dc1ce0f20a57672c Mon Sep 17 00:00:00 2001 From: Barret Schloerke Date: Thu, 2 Jul 2026 10:55:18 -0400 Subject: [PATCH 31/31] test(setup): cover setup() in lapply/replicate (errors via stub) (#68) --- tests/testthat/test-setup.R | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/testthat/test-setup.R b/tests/testthat/test-setup.R index 43e7b15..62cf101 100644 --- a/tests/testthat/test-setup.R +++ b/tests/testthat/test-setup.R @@ -181,6 +181,31 @@ test_that("setup() within a loop is an error", { }) +test_that("setup() inside a nested function (e.g. lapply) errors via the stub", { + # `setup()` is only recognised at the coroutine's top level. Inside a nested + # function it does not cross the function boundary (like `yield()`/`await()`), + # so it stays a plain call to the exported stub and errors -- but with the + # generic message, not the loop-specific one. + gen <- generator(function() { + lapply(1:3, function(i) setup(NULL)) + yield(1) + }) + expect_error(gen()(), "can't be called directly or within function arguments") +}) + + +test_that("setup() inside replicate() (a captured expression) errors via the stub", { + # replicate() captures its second argument as an expression and evaluates it + # inside a generated function, so `setup()` is never compiled as a coroutine + # construct -- it reaches the stub, like the apply family. + gen <- generator(function() { + replicate(2, setup(NULL)) + yield(1) + }) + expect_error(gen()(), "can't be called directly or within function arguments") +}) + + test_that("per-iteration setup/teardown works when delegating to a sub-generator", { the <- new.env() the$x <- 0