tests/testthat/test-with-timeout.R

# M072: one call carries its own limit.
#
# The session-wide option (M69/D047) answers "how long may anything take in
# this session"; `with_timeout()` answers "how long may THIS take", without
# disturbing the answer to the first. Every test below therefore states what
# the session option was before and after, because the second half of the
# promise is that the call leaves it exactly as it found it.

# The seeds a per-call limit has to reach -------------------------------------
#
# `tm_timeout_domain()` (M70) is the set of exports a timeout can be seen
# THROUGH; this is the smaller set where a process is actually started, and it
# is the one a limit has to arrive at. Recorded so a new spawn site reddens it:
# a fifth site added without carrying the limit is exactly the silent hang the
# option seam was built to stop.

test_that("the package starts a process at exactly the four recorded sites", {
  expect_setequal(
    tm_spawn_sites(),
    c("ffmpeg", "ffprobe", "mediainfo", "run_program")
  )
})

test_that("spawn-site membership is read off the body, not off the record", {
  # An empty seed set must collapse the result. Without this the assertion
  # above could be satisfied by a helper that returned its own recorded list.
  expect_equal(tm_spawn_sites(seeds = character(0)), character(0))
  # And a seed no body names adds nobody.
  expect_equal(tm_spawn_sites(seeds = "no_such_primitive"), character(0))
})

# What the call carries, and what it hands back -------------------------------

test_that("expr is evaluated once, in the caller's frame, and its value returned", {
  withr::local_options(tidymedia.timeout = NULL)
  runs <- 0
  factor_here <- 3
  twice <- function() {
    runs <<- runs + 1
    factor_here * 2
  }
  expect_equal(with_timeout(twice(), 30), 6)
  # A promise is forced once, and the wrapper must not force it again on its
  # way out: a second evaluation would re-run whatever the caller wrapped.
  expect_equal(runs, 1)

  # Written where the caller wrote it, not inside the wrapper: this assignment
  # has to land in THIS frame, and the value it captures has to be the limit
  # in force while it ran.
  with_timeout(seen_inside <- getOption("tidymedia.timeout"), 30)
  expect_equal(seen_inside, 30)
})

test_that("the limit in force inside the call is as.numeric(seconds)", {
  withr::local_options(tidymedia.timeout = NULL)
  for (s in list(0, 1L, 60)) {
    expect_identical(
      with_timeout(getOption("tidymedia.timeout"), s),
      as.numeric(s)
    )
  }
})

test_that("a session limit is displaced for the call and restored after it", {
  withr::local_options(tidymedia.timeout = 99)
  expect_identical(with_timeout(getOption("tidymedia.timeout"), 2), 2)
  expect_equal(getOption("tidymedia.timeout"), 99)
})

# AC2 -- the three exits, against both prior states ---------------------------
#
# The erroring exits are the ones on.exit() exists for: a wrapper that restored
# on the returning path alone would leave the caller's session bounded by a
# limit they asked for one call, and a timeout is the abort most likely to be
# the exit taken.

tm_exit_paths <- list(
  returns = function() "value",
  aborts = function() rlang::abort("expr failed", class = "tm_test_failure"),
  times_out = function() abort_timeout("FFmpeg", 2)
)

test_that("a previously-set limit comes back on every exit", {
  for (path in names(tm_exit_paths)) {
    withr::local_options(tidymedia.timeout = 99)
    before <- getOption("tidymedia.timeout")
    f <- tm_exit_paths[[path]]
    if (identical(path, "returns")) {
      expect_equal(with_timeout(f(), 2), "value")
    } else {
      expect_error(with_timeout(f(), 2))
    }
    expect_equal(getOption("tidymedia.timeout"), before, info = path)
  }
})

test_that("a previously-unset limit is unset again on every exit", {
  for (path in names(tm_exit_paths)) {
    withr::local_options(tidymedia.timeout = NULL)
    f <- tm_exit_paths[[path]]
    if (identical(path, "returns")) {
      expect_equal(with_timeout(f(), 2), "value")
    } else {
      expect_error(with_timeout(f(), 2))
    }
    # getOption()'s default fires only when the NAME IS ABSENT, so this
    # distinguishes "unset" from "set to NULL" -- the distinction that makes
    # restoring an unset option a real claim rather than a coincidence.
    expect_equal(
      getOption("tidymedia.timeout", default = "absent"), "absent",
      info = path
    )
  }
})

test_that("each exit path signals what its name says it does", {
  # The loops above accept any error. Which error matters: a `times_out` cell
  # that aborted for some other reason would still restore the option and still
  # pass, certifying nothing about the timeout path.
  expect_equal(tm_exit_paths$returns(), "value")
  expect_error(tm_exit_paths$aborts(), class = "tm_test_failure")
  expect_error(tm_exit_paths$times_out(), class = "tidymedia_timeout")
})

# What `seconds` may be -------------------------------------------------------
#
# The probe vector and the option's verdict on it live in
# `helper-timeout-probes.R`, shared with `local_timeout()`'s refusal test.

test_that("with_timeout() accepts exactly the values the option accepts", {
  verdicts <- vapply(tm_seconds_probes, function(v) {
    by_option <- tm_option_accepts(v)
    by_call <- tm_accepts(function() with_timeout(NULL, v))
    expect_equal(by_call, by_option, info = tm_probe_label(v))
    by_option
  }, logical(1))
  # Not a vacuous agreement: the probe vector has to contain both verdicts, or
  # a wrapper that refused everything would agree with an option that refused
  # everything.
  expect_true(any(verdicts))
  expect_true(any(!verdicts))
})

test_that("a refused seconds stops the call before expr is evaluated", {
  dir <- withr::local_tempdir()
  refused <- Filter(
    function(v) !tm_accepts(function() with_timeout(NULL, v)),
    tm_seconds_probes
  )
  # The refusal set must not be empty, or this test asserts nothing at all.
  expect_gt(length(refused), 0)
  for (i in seq_along(refused)) {
    marker <- file.path(dir, sprintf("ran%d", i))
    expect_error(with_timeout(file.create(marker), refused[[i]]))
    expect_false(file.exists(marker))
  }
})

test_that("the refusal names seconds, not the option", {
  msg <- cli::ansi_strip(conditionMessage(
    expect_error(with_timeout(NULL, 0.5))
  ))
  expect_match(msg, "seconds", fixed = TRUE)
  # The caller wrote an argument, not an option: naming the option here would
  # send them to fix something they never set.
  expect_false(grepl("tidymedia.timeout", msg, fixed = TRUE))
})

# M073 AC1/AC2 -- both formals guarded by the package, not by base R --------
#
# `seconds` has always been checked eagerly; `expr` was left to base R, whose
# "argument \"expr\" is missing, with no default" names the package's internal
# parameter at a caller who wrote a call, not a definition. The cases below are
# derived from `formals()` rather than written out, so a third formal added
# later is guarded or reddens this.

# One valid value per formal. The map is checked against `formals()` below, so
# it cannot silently fall behind the signature.
tm_valid_formals <- list(expr = NULL, seconds = 2)

test_that("an omitted expr is refused by the package, not by base R", {
  err <- expect_error(with_timeout(seconds = 5), class = "rlang_error")
  msg <- cli::ansi_strip(conditionMessage(err))
  expect_match(msg, "expr", fixed = TRUE)
  expect_false(
    grepl("argument \"expr\" is missing, with no default", msg, fixed = TRUE)
  )
})

test_that("every formal of with_timeout() is guarded alike", {
  formal_names <- names(formals(with_timeout))
  # Non-vacuity, both ways: the map must cover the real signature, and there
  # must be more than one formal or "alike" compares nothing.
  expect_setequal(formal_names, names(tm_valid_formals))
  expect_gt(length(formal_names), 1)

  for (omitted in formal_names) {
    supplied <- tm_valid_formals[setdiff(formal_names, omitted)]
    # Against both prior states, because the regression clause below is a claim
    # about the caller's session and "unset" and "set" are different facts.
    for (prior in list(NULL, 99)) {
      withr::local_options(tidymedia.timeout = prior)
      before <- getOption("tidymedia.timeout", default = "absent")
      err <- expect_error(
        do.call(with_timeout, supplied),
        class = "rlang_error"
      )
      msg <- cli::ansi_strip(conditionMessage(err))
      expect_match(msg, omitted, fixed = TRUE, info = omitted)
      # Regression clause: holds today -- the refusals fire before the option
      # is written -- and is pinned here so it keeps holding.
      expect_equal(
        getOption("tidymedia.timeout", default = "absent"),
        before,
        info = paste(omitted, format(prior))
      )
    }
  }
})

test_that("seconds is required, and NULL is not a limit", {
  dir <- withr::local_tempdir()
  missing_marker <- file.path(dir, "missing")
  null_marker <- file.path(dir, "null")
  expect_error(with_timeout(file.create(missing_marker)))
  expect_error(with_timeout(file.create(null_marker), NULL))
  expect_false(file.exists(missing_marker))
  expect_false(file.exists(null_marker))
})

# Where the limit has to arrive ----------------------------------------------
#
# The wrapper writes one process-global option, so nothing is threaded and
# every reader picks it up by construction. "By construction" is exactly the
# kind of claim that stops being true when someone adds a fifth spawn site, so
# each recorded site is driven and asked what limit it was handed.
#
# `guard_timeout()` is mocked rather than a binary run: its `expr` argument is
# lazy, so the mock never forces the `system()`/`system2()` call underneath and
# the cell measures the value, not the runner's PATH.

tm_spawn_call_specs <- list(
  ffmpeg = function() ffmpeg("-version"),
  ffprobe = function() ffprobe("-version"),
  mediainfo = function() mediainfo("--Version"),
  run_program = function() run_program("/bin/echo", "hi", program = "FFmpeg")
)

test_that("every recorded spawn site has a call to drive it", {
  # A new site added to the package without a cell here fails rather than
  # being quietly skipped.
  expect_setequal(names(tm_spawn_call_specs), tm_spawn_sites())
})

test_that("each spawn site is handed the per-call limit", {
  withr::local_options(tidymedia.timeout = NULL)
  seen <- NULL
  testthat::local_mocked_bindings(
    guard_timeout = function(program, limit, expr, ...) {
      seen <<- limit
      character(0)
    },
    .package = "tidymedia"
  )
  for (nm in names(tm_spawn_call_specs)) {
    seen <- NULL
    with_timeout(tm_spawn_call_specs[[nm]](), 7)
    expect_identical(seen, 7, info = nm)

    # The control: with the session option unset and no wrapper, the same site
    # is handed the no-limit sentinel. Without it a site that hard-coded 7
    # would pass the assertion above.
    seen <- NULL
    tm_spawn_call_specs[[nm]]()
    expect_identical(seen, 0, info = nm)
  }
})

test_that("ffm_batch()'s up-front limit check reads the per-call value", {
  # ffm_batch() resolves the limit before it dispatches anything, so that a bad
  # value is refused once in the process that can name the caller (M071/D050).
  # That read is not a spawn site and would not be covered above.
  withr::local_options(tidymedia.timeout = NULL)
  real <- resolve_timeout
  seen <- numeric()
  testthat::local_mocked_bindings(
    resolve_timeout = function(...) {
      value <- real(...)
      seen <<- c(seen, value)
      value
    },
    .package = "tidymedia"
  )
  dir <- withr::local_tempdir()
  input <- file.path(dir, "in.mp4")
  file.create(input)
  jobs <- tibble::tibble(input = input, output = file.path(dir, "out.mp4"))
  build <- function(input, output, ...) ffm_files(input, output)

  with_timeout(ffm_batch(jobs, build, run = FALSE), 7)
  expect_equal(seen, 7)

  seen <- numeric()
  ffm_batch(jobs, build, run = FALSE)
  expect_equal(seen, 0)
})

# A real hung program, bounded by the call and not by the session -------------
#
# The cells above prove the limit is PASSED. This one proves it BITES when the
# session sets no limit at all, which is the case the session-wide option
# cannot cover.
#
# The hang is M69's FIFO nobody writes to: FFmpeg blocks on its header forever
# rather than racing the machine's encoding speed. Slow on Linux and the
# slowness is the feature -- base R escalates SIGINT -> SIGTERM -> SIGKILL
# across limit + 40 s, so this costs ~42 s there against ~2 s on macOS, and
# skips on CRAN with the rest of M69's execution cells.

# tm_release_fifo(): the cell's own outer bound.
#
# Without it, a `with_timeout()` that established nothing would leave FFmpeg
# blocked on a FIFO with no writer and no limit -- and the test would not fail,
# it would hang the runner forever. A background shell opens the FIFO for
# writing well past any limit this test allows and closes it again, so FFmpeg
# reaches EOF and exits. The failure is then an ordinary one: no
# `tidymedia_timeout` abort, or an elapsed time over budget.
#
# M073: the writer is CANCELLED when the frame that armed it ends. The first
# version slept `after` seconds unconditionally, so a suite that finished in two
# seconds still left a 90-second `sleep` behind -- one per cell, outliving the
# process that wanted it. Killing a recorded PID does not solve it: `system(wait
# = FALSE)` hands back the shell's exit status, not a PID, and killing the
# subshell orphans its `sleep` anyway (measured at plan time). So the shell polls
# for a cancel file instead and the arming frame touches that file on its way
# out, by any exit route. What is left running is at most one second-scale
# `sleep`, which ends on its own.
#
# `marker` is a token unique to this call, carried in the command line (via the
# cancel path) so a test can find the process with `pgrep -f` and watch it go.
# It is returned, invisibly, for exactly that purpose.
#
# The `[ -d <tempdir> ]` clause is the session's own deadline. The cancel file
# lives under `tempdir()`, which R removes when the session exits -- so a
# session that ends inside the poll's one-second window takes the cancel file
# with it before the loop ever sees it, and the writer runs out its remaining
# `after` seconds with nobody left to want it (reproduced 2026-08-27: an
# Rscript that armed the helper, cancelled and exited left `sh -c (i=0; ...` in
# the process table 5 s after R was gone). Losing the directory means the same
# thing the cancel file means.
tm_release_fifo <- function(path, after = 90, envir = parent.frame()) {
  marker <- basename(tempfile("tm_fifo_"))
  cancel <- file.path(tempdir(), paste0(marker, ".cancel"))
  withr::defer(file.create(cancel), envir = envir)
  # No trailing `&` of our own: `wait = FALSE` is what backgrounds this, and
  # base R appends the `&` itself -- writing a second one is a shell syntax
  # error, which leaves no writer at all and the bound unarmed (measured
  # 2026-08-27: FFmpeg was still blocked seven minutes in). The enclosing
  # parentheses are load-bearing for the same reason: the appended `&` binds to
  # the LAST command of the string, so without them the poll loop runs in the
  # foreground and blocks R for the full `after` (measured 2026-08-27: 91.8 s
  # against 1.1 s with them).
  system(
    sprintf(
      paste(
        "(i=0; while [ $i -lt %d ]; do [ -f %s ] && exit 0;",
        "[ -d %s ] || exit 0;",
        "i=$((i+1)); sleep 1; done; [ -p %s ] && : > %s) >/dev/null 2>&1"
      ),
      after, shQuote(cancel), shQuote(tempdir()), shQuote(path), shQuote(path)
    ),
    wait = FALSE
  )
  invisible(marker)
}

# tm_pgrep(): the process table, asked about one marker.
#
# `pgrep` exits 1 with no output when nothing matches, which system2() surfaces
# as a `status` attribute rather than an empty result, so the two are folded
# together here.
#
# The bracket is the `grep -v grep` idiom and it is load-bearing. system2() with
# `stdout = TRUE` runs the query through `/bin/sh -c`, whose OWN command line
# contains the pattern -- and dash does not exec away that shell, so the query
# matches itself and can never return empty. Measured on ubuntu-latest
# (dash, procps-ng 4.0.4) 2026-08-27: with no writer running at all,
# `/bin/sh -c "pgrep -af tm_fifo_selfmatch_A"` returned its own PID. That is
# what made every `present = FALSE` assertion in the cell below fail on Linux
# CI while every `present = TRUE` one passed. Bracketing one character makes the
# pattern an ERE that still matches the writer's literal marker but NOT the
# pattern text sitting in the querying shell's command line.
tm_pgrep_pattern <- function(marker) {
  sub("^tm_f", "tm_[f]", marker)
}

tm_pgrep <- function(marker) {
  # shQuote() is load-bearing for the same reason the bracket is. system2() does
  # not quote its arguments and runs the query through a shell, and
  # `tm_[f]ifo_<hex>` is a valid GLOB as well as an ERE -- so a file of that name
  # in the working directory expands the word and hands pgrep the UNBRACKETED
  # marker, restoring the dash self-match this whole helper exists to avoid
  # (measured 2026-08-27: with `tm_fifo_deadbeef` present,
  # `system2("echo", c("-f", "tm_[f]ifo_deadbeef"), stdout = TRUE)` returns
  # `-f tm_fifo_deadbeef`). Nothing creates such a file today; quoting means
  # nothing has to keep not creating one.
  pattern <- shQuote(tm_pgrep_pattern(marker))
  out <- suppressWarnings(
    system2("pgrep", c("-f", pattern), stdout = TRUE, stderr = FALSE)
  )
  if (!is.null(attr(out, "status"))) character(0) else out
}

tm_wait_for_pgrep <- function(marker, present, limit = 5) {
  start <- Sys.time()
  repeat {
    if ((length(tm_pgrep(marker)) > 0) == present) return(TRUE)
    if (as.numeric(difftime(Sys.time(), start, units = "secs")) > limit) {
      return(FALSE)
    }
    Sys.sleep(0.2)
  }
}

# AC3 -- the writer is reaped with its frame -----------------------------------
#
# Each case arms the helper inside a frame, checks the process is really there
# (without that the "gone" assertion below would pass against a helper that
# started nothing at all), then leaves the frame by a different route and waits
# for it to go.

test_that("no process tm_release_fifo() starts outlives the frame", {
  skip_on_cran()
  skip_on_os("windows")
  skip_if(!nzchar(Sys.which("pgrep")), "pgrep is not on the PATH")

  # Never created: the shell only looks at this path after `after` seconds, and
  # every case here cancels long before that.
  path <- file.path(withr::local_tempdir(), "no-such-fifo")

  # The query must not see itself. Without this the three cases below are
  # unfalsifiable on any platform whose `/bin/sh` stays in the process table
  # for the length of the query -- which is what Linux CI does (see
  # tm_pgrep()). A marker of the real shape that nothing ever started must
  # match nothing.
  never_started <- basename(tempfile("tm_fifo_"))
  expect_true(grepl("[[]", tm_pgrep_pattern(never_started)))
  expect_length(tm_pgrep(never_started), 0L)

  armed <- function(marker) {
    expect_true(tm_wait_for_pgrep(marker, present = TRUE), info = "armed")
  }

  # Case 1: the frame returns.
  by_return <- function() {
    marker <- tm_release_fifo(path)
    armed(marker)
    marker
  }
  expect_true(tm_wait_for_pgrep(by_return(), present = FALSE))

  # Case 2: the frame aborts.
  aborted_marker <- NULL
  by_abort <- function() {
    aborted_marker <<- tm_release_fifo(path)
    armed(aborted_marker)
    rlang::abort("frame failed", class = "tm_test_failure")
  }
  expect_error(by_abort(), class = "tm_test_failure")
  expect_true(tm_wait_for_pgrep(aborted_marker, present = FALSE))

  # Case 3: one frame arms the helper twice. Two independent cancel files, both
  # deferred on the same frame, so neither writer may survive it.
  both_markers <- NULL
  twice <- function() {
    both_markers <<- c(tm_release_fifo(path), tm_release_fifo(path))
    for (marker in both_markers) armed(marker)
    invisible(NULL)
  }
  twice()
  expect_length(unique(both_markers), 2)
  for (marker in both_markers) {
    expect_true(tm_wait_for_pgrep(marker, present = FALSE), info = marker)
  }
})

test_that("a per-call limit kills a hung program with no session limit set", {
  blocked <- local_blocking_input()
  out <- withr::local_tempfile(fileext = ".mp4")
  withr::local_options(tidymedia.timeout = NULL)
  tm_release_fifo(blocked)

  start <- Sys.time()
  err <- expect_error(
    with_timeout(
      ffmpeg(paste("-y -i", shQuote(blocked), shQuote(out))),
      2
    ),
    class = "tidymedia_timeout"
  )
  # 2 + base R's 40-second escalation ladder, with room to spare; the outer
  # bound above is far outside this, so a cell that reaches it fails here.
  expect_lt(as.numeric(difftime(Sys.time(), start, units = "secs")), 60)
  msg <- cli::ansi_strip(conditionMessage(err))
  expect_match(msg, "FFmpeg")
  expect_match(msg, "2 seconds")
  # And the session is no more bounded afterwards than it was before.
  expect_equal(getOption("tidymedia.timeout", default = "absent"), "absent")
})

Try the tidymedia package in your browser

Any scripts or data that you put into this service are public.

tidymedia documentation built on Oct. 11, 2026, 5:08 p.m.