R/timeout.R

Defines functions carry_options carried_option_values reraise_absorbed is_absorbed_timeout absorb_timeout guard_timeout abort_timeout is_timeout local_timeout with_timeout resolve_check_tracks resolve_timeout

Documented in local_timeout with_timeout

# Runtime timeout seam -------------------------------------------------------

# The package's second option seam (after `tidymedia.hardware_encoders`), and the
# first that changes what happens rather than what is reported --
# `tidymedia.check_tracks`, below, is the second. Every process tidymedia spawns
# passes resolve_timeout() to base R's `timeout=`, so a hung FFmpeg stops the
# CALL instead of the session (M69/D047). What it does not do
# is bound the program: base R's `timeout=` bounds how long R waits, and the
# program outlives the limit by up to 40 s -- measured at 42.0 s under a 2 s
# limit on Linux and on macOS alike (M078/D056). The FFmpeg build matters too:
# the same blocked input that took 42.0 s against FFmpeg 6.1.1 took 2.0 s
# against 9.0.1, which answers the first signal. A shell child that ignores
# both signals took 42.0 s on macOS. These timings stood in ?tidymedia until
# M127 moved user docs to plain English; ?with_timeout keeps the 40 s bound.
#
# The default is 0 -- base R's sentinel for "no limit" -- so every existing call
# behaves exactly as it did. A ceiling default would abort a legitimate
# multi-hour transcode that finishes today, which is why the seam is opt-in.

# resolve_timeout(): read the option, refuse anything base R would mishandle,
# and return the limit in whole seconds (0 meaning no limit).
#
# The whole-number rule is not fussiness. base R's `timeout=` truncates toward
# zero, so a value below 1 becomes 0 -- and 0 is the "no limit" sentinel, so
# `options(tidymedia.timeout = 0.5)` would silently leave the call UNBOUNDED
# (measured on R 4.6.1: a 6 s child ran to completion under a 0.5 s limit).
# Refusing the fractional value is the only reading that cannot surprise; the
# alternative, rounding up, silently substitutes a limit the caller did not ask
# for. Nothing downstream will catch a bad value for us either: system2()
# accepts both "2" and c(1, 2) without complaint.
#
# Where it is called from is the second half of the rule (M094). This checker
# used to be reached from wherever the limit happened to be read first, which is
# the spawn site -- so `extract_audio()` aborted naming `ffm_run(object)` and
# `probe_all()` naming `purrr::map(infile, probe_one)`, functions the caller
# never typed. Every export in the timeout domain now re-calls it at its own
# front door, applying D042's rule for a builder-bound value: the shared checker
# is called again from the frame that can name the caller, rather than a `call`
# argument being threaded through an exported builder. D074 is where that siting
# is decided, and the `D074:` comment at each site points there rather than at
# its premise (M094 review G6). Three properties come with the siting and are
# worth stating once here rather than at each site:
#
#   * It goes as LATE as the verb allows, but never after a probe or a spawn.
#     In practice that is after the front-door guards and, where the verb builds
#     its pipeline before running anything, after the builder's argument
#     validation too -- so a refusal the VERB itself can reach still fires first
#     and only the blame for this one moves. Not every refusal that fired before
#     it does: a check inside the per-row fan-out (a _batch verb argument such
#     as anonymize_video_batch()'s pixel_format, which
#     tm_corrupt_dropped_master() records) still loses to the limit, and is
#     disclosed in NEWS.md and ?with_timeout (in ?tidymedia until M127) and
#     carried on the ROADMAP rather than fixed (M094 review H1/H3).
#     segment_video()'s outfiles was the other example until M096 put a
#     check_string() loop at its front door, and
#     test-unguarded-argument-front-doors.R asserts that cell has left the
#     census. The OTHER class M094 disclosed -- a check sitting below the
#     build-time nvenc probe, which reads the limit -- is fixed: D075 sites
#     that probe below every check whose answer cannot depend on it, so the
#     argument error is reached before the limit is read (M095).
#     Ordering it against the front door
#     ALONE was measured wrong (M094 review F1): four verbs deliberately keep no
#     front-door guard for `video_codec`/`pixel_format`/`regions`, and a call
#     above ffm_finish() reported the limit where the argument error used to be.
#     A verb reaching a spawn by more than one path carries a call on each
#     (normalize_audio_batch()'s two-pass branch returns above the other).
#     Where the check that has to report first lives in a CALLEE, below the
#     callee's own site, the verb runs that check itself -- the five get_*()
#     scalars call check_path_vector() and resolve_probe()'s infile branch runs
#     probe_all_impl()'s three checks, both above the re-call. That is the same
#     measurement one round later (M094 review G1), at nine exports the first
#     fix did not reach.
#   * It goes ABOVE the `run` gate, so a `run = FALSE` compile is refused too --
#     the batch form already did this (R/ffm_batch.R) and the scalar/batch split
#     was itself the defect.
#   * It is not sited on a path that reads no limit. Two such paths exist, and
#     neither refuses. `has_hardware_encoder()` under a set `tidymedia.hardware_encoders`
#     answers from that option; the memo is NOT part of the carve-out, since
#     inside the fall-through the call sits above cached_encoder_names(), so a
#     warm session memo still refuses and the answer does not depend on what
#     this session happened to ask earlier (M094 review F5). A probe_*()
#     shortcut handed a `probe` object rather than an `infile` reprobes nothing,
#     which is why resolve_probe()'s call sits inside the infile branch. Calling
#     these two the ONE carve-out was measured wrong (M094 review G2).
#
# One probe runs while a command is BUILT rather than at a front door -- the
# nvenc capability lookup, reached from resolve_hw_encoder() inside the pipeline
# and from check_hardware_available() at the fan-out verbs. hardware_encoder_available()
# (R/ffmpeg.R) is has_hardware_encoder()'s body with `call` threaded so that probe refuses
# in the VERB's name; it builds no reached-limit condition, so D049 is untouched.
resolve_timeout <- function(call = rlang::caller_env()) {
  limit <- getOption("tidymedia.timeout", default = 0)
  # `min = 0` covers the negative case; check_number_whole() covers NA, the
  # string form, the length-2 form and the fractional form, and its own message
  # already says "a whole number", which the caller needs to hear.
  rlang::check_number_whole(
    limit,
    min = 0,
    arg = "tidymedia.timeout",
    call = call
  )
  as.numeric(limit)
}

# Dropped-track check seam ---------------------------------------------------

# The package's third option seam. `options(tidymedia.check_tracks = FALSE)`
# switches off D024's dropped-audio-track probe, and with it the one FFprobe
# call that probe costs per distinct input -- the only cost the diagnostic has,
# and one a caller who already knows their inputs' tracks gets nothing for. A
# call that names an `audio_stream` pays nothing either way: the scalar sites
# gate on is.null(audio_stream) and the batch form returns above the seam when
# every row named a track, so those calls never reach the probe at all.
#
# The default is TRUE, so every existing call behaves exactly as it did. That is
# the opposite default from resolve_timeout()'s 0, and for the opposite reason:
# this seam turns an existing behavior OFF, so the reversible default is the
# behavior already shipped.
#
# check_bool() rather than isTRUE(), following resolve_timeout()'s refusal
# rather than base R's coercion. isTRUE() reads every malformed value as FALSE,
# so `options(tidymedia.check_tracks = "yes")` would silently REMOVE the check
# from a session that asked to keep it; check_bool() refuses once, naming the
# option, in the process that can name the caller.
resolve_check_tracks <- function(call = rlang::caller_env()) {
  check <- getOption("tidymedia.check_tracks", default = TRUE)
  rlang::check_bool(check, arg = "tidymedia.check_tracks", call = call)
  check
}

# The caller's per-call limit ------------------------------------------------

#' Set a time limit for one call
#'
#' @description
#' `with_timeout()` runs `expr` with a time limit of its own. The limit applies
#' to each FFmpeg, FFprobe or MediaInfo program that `expr` starts. When
#' `with_timeout()` returns, or stops with an error, the limit that was in
#' force before the call is back.
#'
#' The session limit, `options(tidymedia.timeout = )`, applies to every call in
#' the session. `with_timeout()` applies to one call. For example, you can give
#' one test conversion five minutes in a session with a one-hour limit.
#'
#' @param expr An expression. It is run once, where you wrote it, and its value
#'   is returned.
#' @param seconds A whole number of seconds. `0` means no limit, so
#'   `with_timeout(expr, 0)` removes a session limit for one call. A fraction, a
#'   negative number, a string or `NULL` gives an error before `expr` runs.
#'
#' @return The value of `expr`.
#'
#' @details
#' The limit applies to each program, not to the whole call. In a 100-row batch
#' inside `with_timeout(expr, 600)`, each program that a row starts gets 600
#' seconds, plus the delay in "How long the wait can be". The workers of a
#' `parallel = TRUE` run use the same limit.
#'
#' The limit is a whole number of seconds. The package does not round a
#' fraction, because R would read a limit below one second as no limit.
#'
#' A limit set with `options(tidymedia.timeout = )` follows the same rule, with
#' one difference. `options(tidymedia.timeout = NULL)` removes the option, so
#' it means no limit. A function that can start a program gives an error for
#' a wrong value, even when `run = FALSE`. [ffm_batch()] gives that error
#' before it starts any job. A function that starts no program gives no such
#' error. For example, [has_hardware_encoder()] starts none when you set
#' `tidymedia.hardware_encoders`. A `probe_*()` function that you give a
#' `probe` object also starts none. [has_encoder()] is the exception: it checks
#' the limit first, even on a machine where it finds no FFmpeg to start.
#'
#' Most functions check their own arguments before the limit. So a wrong
#' argument gives its own error, even when the limit is also wrong. A few
#' arguments of the `_batch` functions are checked inside each job, after the
#' limit. An example is the `pixel_format` of [anonymize_video_batch()]. When
#' the limit is also wrong, the error is about the limit.
#'
#' @section How long the wait can be:
#' The limit sets how long R waits for a program, and the wait can be longer.
#' When the limit is reached, R asks the program to stop. R asks again 20
#' seconds later, and kills the program 20 seconds after that. So R can wait up
#' to 40 seconds past the limit. For example, five hung files under a 1-second
#' limit can take about three and a half minutes.
#'
#' R does not guarantee that the program stops. A program can survive the
#' attempts to stop it. How fast a program stops also depends on its version.
#'
#' @section What happens when the limit is reached:
#' A reached limit is never silent. The call gives an error or a warning.
#'
#' These functions give an error with the class `tidymedia_timeout`, which names
#' the program and the limit:
#'
#' * the task functions whose names do not end in `_batch`, except
#'   [segment_video()]
#' * [ffm_run()], [ffmpeg()], [ffprobe()] and [mediainfo()]
#' * [ffmpeg_codecs()] and [ffmpeg_encoders()]
#' * [has_encoder()] and [has_hardware_encoder()] when they ask FFmpeg
#' * [verify_media()], because a check with no answer is not a "no"
#'
#' These functions give a warning instead, so that one hung file does not lose
#' the rest of the work:
#'
#' * [probe_all()], the other `probe_*()` functions, [mediainfo_parameter()],
#'   [mediainfo_query()], [mediainfo_template()] and the `get_*()` functions
#'   give `NA` for that file. One warning at the end says how many files timed
#'   out.
#' * [ffm_batch()], [segment_video()] and the `_batch` task functions set
#'   `success = FALSE` for that job. One warning at the end says how many jobs
#'   timed out. It has the class `tidymedia_batch_timeout`. Two steps of these
#'   calls give an error instead. One is the analysis pass of
#'   `normalize_audio_batch(two_pass = TRUE)`. The other is the check that
#'   FFmpeg has the hardware encoder that `hardware` names, such as `"nvenc"`.
#'   That check asks FFmpeg only when `tidymedia.hardware_encoders` is not set
#'   and the session has no stored answer. The glossary in
#'   `vignette("tidymedia")` explains hardware encoders.
#' * The dropped-track check of [extract_audio()], [convert_audio()],
#'   [normalize_audio()] and their `_batch` forms warns that it could not check.
#'   The track count that [separate_audio_video()] reads after a failed run
#'   warns the same way. So does the check of FFmpeg's encoder lists that
#'   [ffm_run()], the task functions and [ffm_batch()] make after a failed run,
#'   once per call (see "When the build lacks an encoder" on [ffm_run()]). A
#'   batch manifest, see [ffm_manifest()], and
#'   [program_status()] warn when they cannot read a program version. These
#'   warnings have the class `tidymedia_probe_timeout`, and the call goes on as
#'   it would for an unreadable input.
#'
#' `suppressWarnings(classes = "tidymedia_dropped_audio")` hides the
#' dropped-track warning, but not the warning that the check timed out. To hide
#' both, add `"tidymedia_probe_timeout"` to `classes`.
#'
#' The task functions and [ffm_run()] delete a part-written output file after a
#' timeout, as they do after any failed run. [ffmpeg()] cannot tell which of
#' your arguments is the output, so it leaves that file. Check the output of a
#' timed-out [ffmpeg()] call yourself.
#'
#' @seealso [local_timeout()] to set a limit for the rest of a function.
#'   [tidymedia-package] describes the session options.
#'
#' @examples
#' # Inside the call, the limit is the one you gave.
#' with_timeout(getOption("tidymedia.timeout"), 30)
#'
#' # Outside it, the session's own setting is untouched.
#' getOption("tidymedia.timeout", default = "unset")
#'
#' \donttest{
#' # Bound one conversion at five minutes, whatever the session is set to.
#' # Needs FFmpeg, and writes to a temporary file.
#' if (nzchar(Sys.which("ffmpeg"))) {
#'   video <- system.file("extdata", "sample.mp4", package = "tidymedia")
#'   with_timeout(extract_audio(video, tempfile(fileext = ".wav")), 300)
#' }
#' }
#'
#' @export
with_timeout <- function(expr, seconds) {
  # Both formals are guarded here, and both before the option is written. Left
  # to base R, an omitted `expr` said `argument "expr" is missing, with no
  # default` -- naming this function's internal parameter at a caller who wrote
  # a call, not a definition, while an omitted `seconds` already got rlang's
  # own refusal. check_required() does not force the promise (measured
  # 2026-08-27 on rlang 1.3.0), so `expr` is still evaluated once, later, in
  # the caller's frame, under the option.
  rlang::check_required(expr)
  # Eagerly, and BEFORE the option is written: a caller who passed a limit base
  # R cannot use should hear about the limit rather than watch `expr` run
  # unbounded. `arg = "seconds"` because that is the name they wrote --
  # resolve_timeout() names the option instead, for the caller who set one.
  # The check is the same one resolve_timeout() applies, so this function
  # accepts exactly the values the option accepts -- with one exception, and it
  # is deliberate: `options(tidymedia.timeout = NULL)` REMOVES the name, so
  # resolve_timeout() then reads the 0 default and accepts it, while
  # with_timeout(expr, NULL) is a caller naming no limit at all and is refused.
  rlang::check_number_whole(seconds, min = 0, arg = "seconds")
  # options() returns the prior value of exactly the name being set, and
  # on.exit() puts it back on the erroring path as well as the returning one --
  # the pair carry_options() already uses below, for the same reason. A name
  # that was UNSET comes back as a NULL entry, and options(list(x = NULL))
  # removes the name rather than storing NULL, so an unset option is unset
  # again afterwards (measured on R 4.6.1).
  prior <- options(tidymedia.timeout = as.numeric(seconds))
  on.exit(options(prior), add = TRUE)
  # `expr` is a promise, so forcing it here evaluates it once, in the caller's
  # frame, under the option just established -- and the restore above runs
  # after that value is in hand.
  expr
}

#' Set a time limit for the rest of a function
#'
#' @description
#' `local_timeout()` sets a time limit for the rest of the function that calls
#' it. The limit applies to each FFmpeg, FFprobe or MediaInfo program that the
#' function starts after this call. When the function returns, or stops with an
#' error, the caller's own limit is back, except in the cases in Details.
#'
#' Use [with_timeout()] to set a limit on one expression. Use `local_timeout()`
#' to set a limit on the rest of a function, or on several calls that are hard
#' to wrap in one expression.
#'
#' @param seconds A whole number of seconds. `0` means no limit, so
#'   `local_timeout(0)` removes a session limit for the rest of the function. A
#'   fraction, a negative number, a string or `NULL` gives an error, and the
#'   limit does not change.
#' @param .local_envir The environment that holds the limit. The default is the
#'   function that calls `local_timeout()`. Change it only when you write a
#'   helper that sets a limit for its own caller. Inside a function, the
#'   environment must belong to a function that is still running. Suppose it
#'   belongs to a function that has returned, or it is an environment such as
#'   `new.env()`. Then the limit stays set with no error.
#'   [withr::local_options()] works the same way. At the top level of a
#'   script or the console, [withr::defer()] decides when the limit is undone.
#'
#' @return The caller's earlier setting, invisibly. It is a list with one
#'   element, the same form that [withr::local_options()] returns.
#'
#' @details
#' The limit applies to each program, not to the whole function. In a 100-row
#' batch after `local_timeout(600)`, each program that a row starts gets 600
#' seconds, plus the delay that [with_timeout()] describes. The workers of a
#' `parallel = TRUE` run use the same limit.
#'
#' R can wait up to 40 seconds past the limit, and [with_timeout()] explains
#' why. It also explains what happens when a limit is reached.
#'
#' Two calls in one function work like any two `local_*()` calls. The second
#' limit applies until the function ends. Then both are undone, and the caller's
#' limit is back.
#'
#' In three cases, the caller's limit is not back when the function ends, and
#' there is no error.
#'
#' * The function calls `on.exit()` without `add = TRUE`. That call removes the
#'   undo step. Write `on.exit(..., add = TRUE)` instead.
#' * The `.local_envir` belongs to a function that has returned, or it is an
#'   environment such as `new.env()`.
#' * `local_timeout()` is called directly inside the expression of
#'   [with_timeout()]. There, `local_timeout()` belongs to the function around
#'   it. So when that function ends, the limit that [with_timeout()] set is in
#'   force. Put the inner limit in a function of its own, or use only one of
#'   the two.
#'
#' The first two cases also apply to [withr::local_options()], because R's exit
#' handlers work this way. They do not apply to [withr::with_options()], which
#' puts the option back itself.
#'
#' @seealso [with_timeout()] to set a limit for one expression.
#'   [tidymedia-package] describes the session options.
#'
#' @examples
#' bounded <- function() {
#'   local_timeout(30)
#'   getOption("tidymedia.timeout")
#' }
#'
#' # In force for the rest of that function...
#' bounded()
#'
#' # ...and gone once it has returned.
#' getOption("tidymedia.timeout", default = "unset")
#'
#' # Bound every program a whole function starts, at five minutes. Defining the
#' # function starts nothing; the limit applies when you call it.
#' convert_all <- function(files) {
#'   local_timeout(300)
#'   for (f in files) extract_audio(f, sub("[.][^.]*$", ".wav", f))
#' }
#'
#' @export
local_timeout <- function(seconds, .local_envir = parent.frame()) {
  # The same check with_timeout() applies, before anything is written, so a
  # caller who mistyped a limit hears about it rather than running the rest of
  # the frame under a limit they did not ask for.
  rlang::check_number_whole(seconds, min = 0, arg = "seconds")
  # The prior value is READ, the undo REGISTERED, and only then the new value
  # WRITTEN -- withr::local_options()'s own order, and the reason for it is that
  # defer() can fail. A `.local_envir` that is not an environment aborts inside
  # defer(), and with the write already done there is nothing left to put the
  # caller's value back: the limit would stay set for the rest of the session
  # (measured 2026-08-27, option left at this function's value where the caller
  # had 99, against 99 through withr::local_options() given the same bad
  # argument). Registering first makes any failure below leave the session as it
  # was found.
  #
  # `list(name = getOption(name))` is the same shape options() returns for the
  # name being set -- length 1, the entry NULL when the option is unset
  # (verified identical on R 4.6.1) -- and feeding a NULL entry back to
  # options() REMOVES the name rather than storing NULL, so an unset option is
  # unset again afterwards. Same pair with_timeout() uses.
  prior <- list(tidymedia.timeout = getOption("tidymedia.timeout"))
  # withr::defer() rather than base on.exit(): it PREPENDS its handler
  # (`after = FALSE`), which is what makes two calls in one frame restore to the
  # CALLER's state rather than to the first call's, where a plain
  # `on.exit(add = TRUE)` appends and restores to the first call's. It also
  # handles a global or knitr target environment, which a hand-rolled
  # `do.call(on.exit, ..., envir = )` does not.
  #
  # What it does NOT buy is an unclobberable restore, and an earlier version of
  # this comment claimed it did. defer() ends in
  # `do.call(base::on.exit, list(thunk, TRUE, after), envir = envir)`, so a
  # calling frame writing its own `on.exit()` without `add = TRUE` discards this
  # exactly as it would a base one -- measured 2026-08-27 on withr 3.0.3 and on
  # 2.5.0, the declared floor, with the option left at this function's value
  # where the caller had 99 on both. That hole is stated in the @details above
  # rather than papered over.
  #
  # The version spread is measured, not assumed: withr 3.0.0 rewrote defer()'s
  # globalenv() branch, and local_timeout() hands it globalenv() from both
  # top-level forms -- parent.frame() is globalenv() at the top level of a file
  # run by Rscript and of a source()d file alike, measured TRUE on 2.5.0 and on
  # 3.0.3. Only the Rscript form's undo actually lands there, though:
  # deferred_run(globalenv()) restores the caller's value at an Rscript top
  # level and finds nothing to run inside a source()d file, on both versions.
  # That is the measured part; withr's own sources say why (both redirect the
  # handler to source()'s own frame first, by different routes), read from those
  # sources rather than by the harness -- D053 records the reading and where it
  # came from. So the rewritten branch is reached from one of the two forms, and
  # what the caller observes is the same on both versions either way.
  # data-raw/withr-floor.R re-runs the whole comparison -- including the withr::
  # calls the @details above compare this one to -- and D053 records what it
  # found, the one form where the two versions part included.
  #
  # Written directly inside a with_timeout() expression, this call binds to the
  # frame that wrote it, so its undo runs after the wrapper's and leaves the
  # wrapper's limit set. withr::with_options() and withr::local_options() do the
  # same (measured 2026-08-27 on withr 2.5.0 and 3.0.3; the ?local_timeout text
  # carried the dates until M127).
  withr::defer(options(prior), envir = .local_envir)
  options(tidymedia.timeout = as.numeric(seconds))
  invisible(prior)
}

# is_timeout(): did this result come back because the limit ended the WAIT?
#
# Not the same as "the limit killed the child": R stops waiting either way, and
# whether the program died depends on whether it answered a signal (M078/D056).
#
# Keyed on the `status` attribute, NEVER on the text of R's timeout warning.
# That warning is translated under a non-English locale -- under LANGUAGE=de it
# reads "Zeitüberschreitung bei Kommando ..." -- so a text match is a guard that
# silently stops working abroad (M46's lesson, paid for once already).
#
# `limit > 0` is load-bearing, not belt-and-braces: 124 is an ordinary exit
# status a program may return for its own reasons, and it only means "killed by
# the timeout" when a timeout was actually in force.
is_timeout <- function(out, limit) {
  if (limit <= 0) return(FALSE)
  status <- attr(out, "status")
  # system() and system2() are not consistent about typing the status, so
  # compare as integer rather than with identical() on the raw value.
  !is.null(status) && identical(as.integer(status), 124L)
}

# abort_timeout(): the one refusal every spawn site shares.
#
# Names the program and the limit and NOTHING ELSE. R's own timeout warning
# embeds the full command line, including the `input=` temp-file path; the spawn
# sites drop that warning and this message replaces it, so the path never
# reaches the caller (M46 review finding B, same trap).
#
# `extra` appends caller-supplied bullets (ffm_run() adds D046's output
# disposition). `.envir` is what makes that safe: those bullets carry cli fields
# like `{.file {output}}` that resolve only in the CALLER's frame, and cli
# interpolates every bullet in one environment. The caller therefore passes its
# own frame and defines `program`/`limit` there -- which is why the handler in
# ffm_run() reads them off the condition into locals rather than reusing the
# already-formatted message. Re-interpolating that message would re-run glue
# over user data, which is M44's brace trap.
#
# `program` and `limit` also ride on the condition so a handler can rebuild this
# refusal without parsing its text.
abort_timeout <- function(program, limit, extra = NULL,
                          call = rlang::caller_env(),
                          .envir = rlang::current_env()) {
  cli::cli_abort(
    c(
      "{program} timed out after {limit} second{?s}.",
      "i" = "Raise or remove the limit with \\
             {.code options(tidymedia.timeout = )}; {.code 0} means no limit.",
      extra
    ),
    class = "tidymedia_timeout",
    tm_program = program,
    tm_limit = limit,
    call = call,
    .envir = .envir
  )
}

# guard_timeout(): the one wrapper every spawn site shares.
#
# Evaluates `expr` (a system()/system2() call, lazily, inside the handler),
# holds every warning it signals, and turns a reached limit into abort_timeout().
#
# Warnings are HELD rather than filtered in the handler because the timeout is
# identified by the status, which is not known until the call returns -- the
# same ordering constraint the test-side helper hit at M46. They are held
# unconditionally and dropped on the timeout path, because R's timeout warning
# carries the full command line and the `input=` temp path with it.
#
# `suppress` follows the site's EXISTING behavior rather than imposing one:
# run_program() has always wrapped its system2() in suppressWarnings(), while
# the three Layer 0 hatches have always let a non-zero exit warn. Changing
# either would be a behavior change this milestone did not promise.
guard_timeout <- function(program, limit, expr, suppress = FALSE,
                          call = rlang::caller_env()) {
  held <- character()
  out <- withCallingHandlers(
    expr,
    warning = function(w) {
      held <<- c(held, conditionMessage(w))
      invokeRestart("muffleWarning")
    }
  )
  if (is_timeout(out, limit)) abort_timeout(program, limit, call = call)
  if (!suppress) for (msg in held) warning(msg, call. = FALSE)
  out
}

# absorb_timeout(): let a resilient reader count a timeout as one more file it
# could not read.
#
# The metadata readers each document an NA row (or NA value) plus one
# end-of-call warning for a file they cannot read, and D047 makes a timeout no
# exception -- the readers absorb it exactly as they absorb any other failure.
# Without this the abort escapes probe_all()'s purrr::map() and the MediaInfo
# readers' per-file loop, so ONE hung file in a 500-file corpus discards every
# other file's result and falsifies the @return the caller read.
#
# Scoped to `tidymedia_timeout` alone: every other error still propagates, so
# this is a narrow absorber and not a blanket try().
#
# What comes back is a SENTINEL rather than the `NULL` a reader already uses for
# "unreadable", because those two outcomes are not the same fact and the reader
# has to be able to tell them apart. Returning `NULL` for both made a hung file
# indistinguishable from a corrupt one, which is how `ffm_run(verify = )` came
# to report a hung FFprobe as "width: expected 1920, got NA" -- blaming a
# successful encode for the wrong reason. The sentinel carries the program and
# the limit off the condition so a caller that must not absorb (verify_media())
# can rebuild the refusal without parsing anyone's message.
absorb_timeout <- function(expr) {
  rlang::try_fetch(
    expr,
    tidymedia_timeout = function(cnd) {
      structure(
        list(program = cnd$tm_program, limit = cnd$tm_limit),
        class = "tidymedia_absorbed_timeout"
      )
    }
  )
}

is_absorbed_timeout <- function(x) inherits(x, "tidymedia_absorbed_timeout")

# reraise_absorbed(): turn a sentinel back into the abort it stands for.
#
# For the one caller that must not absorb. `verify_media()` asks whether a file
# HAS given properties; a probe that never answered is not an answer of "no",
# so it refuses rather than reporting every property as a mismatch.
reraise_absorbed <- function(x, call = rlang::caller_env()) {
  abort_timeout(x$program, x$limit, call = call)
}

# Carrying the caller's settings into a worker --------------------------------

# tidymedia's two option seams are read in the process that evaluates the call,
# and a `parallel = TRUE` fan-out evaluates its mapped call somewhere else. A
# `future` worker starts from that worker's own options -- `future` exports the
# closure's globals, not the parent's option list -- so a limit or an encoder
# override the caller set was read as UNSET inside the worker and the parallel
# path silently diverged from the sequential one (measured 2026-08-26 on future
# 1.75.0 / furrr 0.4.0: a multisession worker read `tidymedia.timeout` as unset
# against `42` in the parent).
#
# The fix is a wrapper, captured in the PARENT at fan-out time and shipped to
# the worker as part of the mapped closure. It re-establishes values the caller
# set; it does not author values of its own.

# carried_option_values(): what a fan-out carries, resolved in the parent.
#
# The timeout is carried RESOLVED rather than raw, so a value base R would
# mishandle is refused once, here, in the process that can name the caller --
# rather than N times inside workers, below the per-job tryCatch that turns an
# error into a bare `success = FALSE`. Resolving has a consequence worth naming:
# resolve_timeout() answers 0 for an unset option, so a parent with no limit
# carries the no-limit SENTINEL rather than the unset state. That is the one
# value here the package chooses rather than the caller, and it makes the two
# seams asymmetric -- an unset encoder override is carried as unset, an unset
# limit as `0`. The effect is the same for a worker with no limit of its own,
# and it displaces one that had its own limit set through a plan hook (D050's
# named falsifier), which is why it is stated rather than left to be inferred.
#
# The encoder override is carried as-is, including its unset state. What is NOT
# carried is the session capability memo (`R/cache.R`): a worker with no
# override still asks its own FFmpeg -- D044's per-process gap, unchanged.
#
# The track check joins as the third seam (M082), carried RAW like the encoder
# override rather than resolved like the limit. Resolving buys nothing here:
# the dropped-track probe runs at the front door, in this very process, so a
# malformed value has already been refused by the time a worker could see one --
# and on the one path that skips the front-door probe entirely (a batch whose
# every row named a track) no worker reads the option at all. Carrying it raw
# also keeps the unset state unset, so a worker that has its own answer through
# a plan hook keeps it, which the resolved limit cannot do.
carried_option_values <- function(call = rlang::caller_env()) {
  list(
    tidymedia.timeout = resolve_timeout(call = call),
    tidymedia.hardware_encoders = getOption("tidymedia.hardware_encoders"),
    tidymedia.check_tracks = getOption("tidymedia.check_tracks")
  )
}

# carry_options(): wrap a mapped function so it runs under `values`.
#
# `options()` is the whole mechanism, in both directions: it returns the prior
# values of exactly the names being set, and a NULL entry REMOVES an option
# rather than storing NULL (measured on R 4.6.1). So a name carried as unset is
# unset in the worker for the duration of the call, and a name the worker had
# set for itself comes back on the way out -- one rule, no split behavior. What
# counts as "unset" is decided above, in carried_option_values(): the encoder
# override is carried raw, the limit is carried resolved, so only the former can
# reach here unset.
#
# on.exit() rather than a trailing restore, because the restore has to happen on
# the error path too: a mapped call that aborts (a timeout is one) must not
# leave the parent's settings behind for whatever the next chunk maps.
carry_options <- function(.f, values = carried_option_values(call = call),
                          call = rlang::caller_env()) {
  force(.f)
  force(values)
  function(...) {
    prior <- options(values)
    on.exit(options(prior), add = TRUE)
    .f(...)
  }
}

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.