From 5ac96f6be6be5456b9d2b2dcae0f092fead4bc7d Mon Sep 17 00:00:00 2001 From: Lionel Henry Date: Wed, 19 Apr 2023 17:55:56 +0200 Subject: [PATCH 01/21] Extract `c_processx_wait()` And restore old sigmasks --- src/unix/processx-unix.h | 4 ++++ src/unix/processx.c | 34 ++++++++++++++++++++++------------ src/unix/sigchld.c | 15 +++++++++++++-- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/unix/processx-unix.h b/src/unix/processx-unix.h index f9bbe675..1f97bf40 100644 --- a/src/unix/processx-unix.h +++ b/src/unix/processx-unix.h @@ -36,7 +36,11 @@ void processx__sigchld_callback(int sig, siginfo_t *info, void *ctx); void processx__setup_sigchld(void); void processx__remove_sigchld(void); void processx__block_sigchld(void); +void processx__block_sigchld_save(sigset_t *old); void processx__unblock_sigchld(void); +void processx__procmask_set(sigset_t *set); + +int c_processx_wait(processx_handle_t *handle, int timeout, const char *name); void processx__finalizer(SEXP status); diff --git a/src/unix/processx.c b/src/unix/processx.c index a188f6ec..81687e50 100644 --- a/src/unix/processx.c +++ b/src/unix/processx.c @@ -674,30 +674,38 @@ static void processx__wait_cleanup(void *ptr) { SEXP processx_wait(SEXP status, SEXP timeout, SEXP name) { processx_handle_t *handle = R_ExternalPtrAddr(status); + int ctimeout = INTEGER(timeout)[0]; const char *cname = isNull(name) ? "???" : CHAR(STRING_ELT(name, 0)); - int ctimeout = INTEGER(timeout)[0], timeleft = ctimeout; + + int ret = c_processx_wait(handle, ctimeout, cname); + return ScalarLogical(ret); +} + +int c_processx_wait(processx_handle_t *handle, int timeout, const char *name) { struct pollfd fd; int ret = 0; pid_t pid; + int timeleft = timeout; int *fds = malloc(sizeof(int) * 2); if (!fds) R_THROW_SYSTEM_ERROR("Allocating memory when waiting"); fds[0] = fds[1] = -1; r_call_on_exit(processx__wait_cleanup, fds); - processx__block_sigchld(); + sigset_t old; + processx__block_sigchld_save(&old); if (!handle) { - processx__unblock_sigchld(); - return ScalarLogical(1); + processx__procmask_set(&old); + return 1; } pid = handle->pid; /* If we already have the status, then return now. */ if (handle->collected) { - processx__unblock_sigchld(); - return ScalarLogical(1); + processx__procmask_set(&old); + return 1; } /* Make sure this is active, in case another package replaced it... */ @@ -706,8 +714,8 @@ SEXP processx_wait(SEXP status, SEXP timeout, SEXP name) { /* Setup the self-pipe that we can poll */ if (pipe(handle->waitpipe)) { - processx__unblock_sigchld(); - R_THROW_SYSTEM_ERROR("processx error when waiting for '%s'", cname); + processx__procmask_set(&old); + R_THROW_SYSTEM_ERROR("processx error when waiting for '%s'", name); } fds[0] = handle->waitpipe[0]; fds[1] = handle->waitpipe[1]; @@ -723,7 +731,7 @@ SEXP processx_wait(SEXP status, SEXP timeout, SEXP name) { - while (ctimeout < 0 || timeleft > PROCESSX_INTERRUPT_INTERVAL) { + while (timeout < 0 || timeleft > PROCESSX_INTERRUPT_INTERVAL) { do { ret = poll(&fd, 1, PROCESSX_INTERRUPT_INTERVAL); } while (ret == -1 && errno == EINTR); @@ -743,7 +751,7 @@ SEXP processx_wait(SEXP status, SEXP timeout, SEXP name) { goto cleanup; } - if (ctimeout >= 0) timeleft -= PROCESSX_INTERRUPT_INTERVAL; + if (timeout >= 0) timeleft -= PROCESSX_INTERRUPT_INTERVAL; } /* Maybe we are not done, and there is a little left from the timeout */ @@ -755,7 +763,7 @@ SEXP processx_wait(SEXP status, SEXP timeout, SEXP name) { if (ret == -1) { R_THROW_SYSTEM_ERROR("processx wait with timeout error while " - "waiting for '%s'", cname); + "waiting for '%s'", name); } cleanup: @@ -763,7 +771,9 @@ SEXP processx_wait(SEXP status, SEXP timeout, SEXP name) { handle->waitpipe[0] = -1; handle->waitpipe[1] = -1; - return ScalarLogical(ret != 0); + processx__procmask_set(&old); + + return ret != 0; } /* This is similar to `processx_wait`, but a bit simpler, because we diff --git a/src/unix/sigchld.c b/src/unix/sigchld.c index cad171bb..5b53e66b 100644 --- a/src/unix/sigchld.c +++ b/src/unix/sigchld.c @@ -128,15 +128,26 @@ void processx__remove_sigchld(void) { memset(&old_sig_handler, 0, sizeof(old_sig_handler)); } -void processx__block_sigchld(void) { +void processx__block_sigchld_save(sigset_t *old) { sigset_t blockMask; sigemptyset(&blockMask); sigaddset(&blockMask, SIGCHLD); - if (sigprocmask(SIG_BLOCK, &blockMask, NULL) == -1) { + + if (sigprocmask(SIG_BLOCK, &blockMask, old) == -1) { R_THROW_ERROR("processx error setting up signal handlers"); } } +void processx__procmask_set(sigset_t *set) { + if (sigprocmask(SIG_SETMASK, set, NULL) == -1) { + R_THROW_ERROR("processx error setting up signal handlers"); + } +} + +void processx__block_sigchld(void) { + processx__block_sigchld_save(NULL); +} + void processx__unblock_sigchld(void) { sigset_t unblockMask; sigemptyset(&unblockMask); From 42699d432fd52b35ce6fa2c2b02624caf60a5f99 Mon Sep 17 00:00:00 2001 From: Lionel Henry Date: Wed, 19 Apr 2023 17:54:13 +0200 Subject: [PATCH 02/21] Implement `kill(grace = )` --- DESCRIPTION | 3 +- NEWS.md | 5 +++ R/process.R | 3 +- R/utils.R | 21 ++++++++++++ src/Makevars | 6 +++- src/install.libs.R | 5 +++ src/test/sigtermignore.c | 11 +++++++ src/unix/processx.c | 27 ++++++++++++---- tests/testthat/helper.R | 5 +++ tests/testthat/test-process.R | 60 +++++++++++++++++++++++++++++++++++ 10 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 src/test/sigtermignore.c diff --git a/DESCRIPTION b/DESCRIPTION index 507aa4ac..ef2817ba 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -31,12 +31,13 @@ Suggests: curl, debugme, parallel, + pkgload, rlang (>= 1.0.2), testthat (>= 3.0.0), webfakes, withr Encoding: UTF-8 -RoxygenNote: 7.2.0 +RoxygenNote: 7.2.3 Roxygen: list(markdown = TRUE) Config/testthat/edition: 3 Config/Needs/website: tidyverse/tidytemplate diff --git a/NEWS.md b/NEWS.md index 4dc1f5a2..11c04182 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,10 @@ # processx (development version) +* The `grace` argument of the `kill()` method is now active on Unix + platforms. processx first tries to kill with `SIGTERM` with a + timeout of `grace` seconds. After the timeout, `SIGKILL` is sent as + before. + # processx 3.8.1 * On Unixes, R processes created by callr now feature a `SIGTERM` diff --git a/R/process.R b/R/process.R index f0b2b421..eefd5393 100644 --- a/R/process.R +++ b/R/process.R @@ -737,7 +737,8 @@ process_interrupt <- function(self, private) { process_kill <- function(self, private, grace, close_connections) { "!DEBUG process_kill '`private$get_short_name()`', pid `self$get_pid()`" - ret <- chain_call(c_processx_kill, private$status, as.numeric(grace), + + ret <- chain_clean_call(c_processx_kill, private$status, as.numeric(grace), private$get_short_name()) if (close_connections) private$close_connections() ret diff --git a/R/utils.R b/R/utils.R index 5a78f802..f3c95711 100644 --- a/R/utils.R +++ b/R/utils.R @@ -300,3 +300,24 @@ defer <- function(expr, frame = parent.frame(), after = FALSE) { thunk <- as.call(list(function() expr)) do.call(on.exit, list(thunk, add = TRUE, after = after), envir = frame) } + +rimraf <- function(...) { + x <- file.path(...) + if ("~" %in% x) stop("Cowardly refusing to delete `~`") + unlink(x, recursive = TRUE, force = TRUE) +} + +get_test_lib <- function(lib) { + if (pkgload::is_dev_package("processx")) { + path <- "src" + } else { + path <- paste0('libs', .Platform$r_arch) + } + + system.file( + package = "processx", + path, + "test", + paste0(lib, .Platform$dynlib.ext) + ) +} diff --git a/src/Makevars b/src/Makevars index 2464737d..0138c5d9 100644 --- a/src/Makevars +++ b/src/Makevars @@ -8,7 +8,8 @@ OBJECTS = init.o poll.o errors.o processx-connection.o \ .PHONY: all clean -all: tools/px tools/sock supervisor/supervisor client$(SHLIB_EXT) $(SHLIB) +all: tools/px tools/sock supervisor/supervisor client$(SHLIB_EXT) $(SHLIB) \ + test/sigtermignore$(SHLIB_EXT) tools/px: tools/px.c $(CC) $(CFLAGS) $(LDFLAGS) -Wall tools/px.c -o tools/px @@ -30,6 +31,9 @@ client$(SHLIB_EXT): $(CLIENT_OBJECTS) patchelf --remove-needed libR.so client$(SHLIB_EXT); \ fi +test/sigtermignore$(SHLIB_EXT): test/sigtermignore.o + $(SHLIB_LINK) -o test/sigtermignore$(SHLIB_EXT) test/sigtermignore.o + clean: rm -rf $(SHLIB) $(OBJECTS) $(CLIENT_OBJECTS) \ supervisor/supervisor supervisor/supervisor.dSYM \ diff --git a/src/install.libs.R b/src/install.libs.R index 20be5d58..4a0b0353 100644 --- a/src/install.libs.R +++ b/src/install.libs.R @@ -18,3 +18,8 @@ file.copy(files, dest, overwrite = TRUE) if (file.exists("symbols.rds")) { file.copy("symbols.rds", dest, overwrite = TRUE) } + +test_files <- Sys.glob(paste0("test/*", SHLIB_EXT)) +test_dest <- file.path(dest, "test") +dir.create(test_dest, recursive = TRUE, showWarnings = FALSE) +file.copy(test_files, test_dest, overwrite = TRUE) diff --git a/src/test/sigtermignore.c b/src/test/sigtermignore.c new file mode 100644 index 00000000..b564fbfd --- /dev/null +++ b/src/test/sigtermignore.c @@ -0,0 +1,11 @@ +#ifndef _WIN32 + +#include +#include +#include + +void R_init_sigtermignore(DllInfo *dll) { + signal(SIGTERM, SIG_IGN); +} + +#endif diff --git a/src/unix/processx.c b/src/unix/processx.c index 81687e50..7be6a810 100644 --- a/src/unix/processx.c +++ b/src/unix/processx.c @@ -1007,14 +1007,29 @@ SEXP processx_kill(SEXP status, SEXP grace, SEXP name) { /* If the process is not running, return (FALSE) */ if (wp != 0) { goto cleanup; } - /* It is still running, so a SIGKILL */ - int ret = kill(-pid, SIGKILL); - if (ret == -1 && (errno == ESRCH || errno == EPERM)) { goto cleanup; } - if (ret == -1) { - processx__unblock_sigchld(); - R_THROW_SYSTEM_ERROR("process_kill for '%s'", cname); + /* It is still running, send a SIGTERM if gracious */ + double grace_ms = REAL(grace)[0] * 1000; + +#define KILL_WITH(SIG) do { \ + int ret = kill(-pid, SIG); \ + if (ret == -1 && (errno == ESRCH || errno == EPERM)) { goto cleanup; } \ + if (ret == -1) { \ + processx__unblock_sigchld(); \ + R_THROW_SYSTEM_ERROR("process_kill for '%s'", cname); \ + } \ +} while(0) + + if (grace_ms) { + KILL_WITH(SIGTERM); + if (c_processx_wait(handle, grace_ms, cname)) { + result = handle->exitcode == -SIGTERM; + goto cleanup; + } } + /* It is still running, send a SIGKILL */ + KILL_WITH(SIGKILL); + /* Do a waitpid to collect the status and reap the zombie */ do { wp = waitpid(pid, &wstat, 0); diff --git a/tests/testthat/helper.R b/tests/testthat/helper.R index e541087c..9053eade 100644 --- a/tests/testthat/helper.R +++ b/tests/testthat/helper.R @@ -166,3 +166,8 @@ poll_until <- function(fn, interrupt = 0.2, timeout = 5) { skip_on_cran() stop("timeout") } + +load_sigtermignore <- function() { + lib <- asNamespace("processx")$get_test_lib("sigtermignore") + dyn.load(lib) +} diff --git a/tests/testthat/test-process.R b/tests/testthat/test-process.R index 911ab483..0e202e3d 100644 --- a/tests/testthat/test-process.R +++ b/tests/testthat/test-process.R @@ -228,3 +228,63 @@ test_that("can exit or sigkill parent of cleanup process", { tools::pskill(p$get_pid(), tools::SIGKILL) poll_until(function() !ps::ps_is_running(cleanup_p)) }) + +test_that("can kill process with grace", { + # https://github.com/r-lib/callr/pull/250 + skip_if_not_installed("callr", "3.7.3.9001") + + withr::local_envvar("PROCESSX_R_SIGTERM_CLEANUP" = "true") + + # Write subprocess `tempdir()` to this file + out <- tempfile() + defer(rimraf(out)) + + fn <- function(file) { + file.create(tempfile()) + cat(paste0(tempdir(), "\n"), file = file) + } + get_temp_dir <- function(frame = parent.frame()) { + dir <- readLines(out) + expect_length(dir, 1) + defer(rimraf(dir), frame = frame) + dir + } + + # Check that SIGTERM was called on subprocess by examining side + # effect of tempdir cleanup + p <- callr::r_session$new() + p$run(fn, list(file = out)) + dir <- get_temp_dir() + p$kill(grace = 0.1) + expect_false(dir.exists(dir)) + + # When `grace` is 0, the tempdir isn't cleaned up + p <- callr::r_session$new() + p$run(fn, list(file = out)) + dir <- get_temp_dir() + p$kill(grace = 0) + expect_true(dir.exists(dir)) +}) + +test_that("can load sigtermignore", { + p <- callr::r_session$new() + defer(p$kill()) + + p$run(load_sigtermignore) + + tools::pskill(p$get_pid(), tools::SIGTERM) + tools::pskill(p$get_pid(), tools::SIGTERM) + + expect_true(p$is_alive()) +}) + +test_that("can kill with SIGTERM when ignored", { + p <- callr::r_session$new() + defer(p$kill()) + + p$run(load_sigtermignore) + + p$signal(tools::SIGTERM) + Sys.sleep(0.05) + expect_true(p$is_alive()) +}) From dcb71f5c0cefcb183bbb3beddabc6df171890c5e Mon Sep 17 00:00:00 2001 From: Lionel Henry Date: Wed, 19 Apr 2023 18:03:55 +0200 Subject: [PATCH 03/21] Add `cleanup_grace` argument to `run()` --- R/assertions.R | 4 ++++ R/run.R | 10 ++++++---- man/run.Rd | 3 +++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/R/assertions.R b/R/assertions.R index c1f3d078..e78d3c67 100644 --- a/R/assertions.R +++ b/R/assertions.R @@ -27,6 +27,10 @@ on_failure(is_flag) <- function(call, env) { paste0(deparse(call$x), " is not a flag (length 1 logical)") } +is_numeric_scalar <- function(x) { + is.numeric(x) && length(x) == 1 && !is.na(x) +} + is_integerish_scalar <- function(x) { is.numeric(x) && length(x) == 1 && !is.na(x) && round(x) == x } diff --git a/R/run.R b/R/run.R index 52eaf096..3df8f780 100644 --- a/R/run.R +++ b/R/run.R @@ -123,6 +123,7 @@ #' both streams in UTF-8 currently. #' @param cleanup_tree Whether to clean up the child process tree after #' the process has finished. +#' @param cleanup_grace Passed to `kill()` or `kill_tree()` on cleanup. #' @param ... Extra arguments are passed to `process$new()`, see #' [process]. Note that you cannot pass `stout` or `stderr` here, #' because they are used internally by `run()`. You can use the @@ -162,7 +163,7 @@ run <- function( stderr_line_callback = NULL, stderr_callback = NULL, stderr_to_stdout = FALSE, env = NULL, windows_verbatim_args = FALSE, windows_hide_window = FALSE, - encoding = "", cleanup_tree = FALSE, ...) { + encoding = "", cleanup_tree = FALSE, cleanup_grace = 0.1, ...) { assert_that(is_flag(error_on_status)) assert_that(is_time_interval(timeout)) @@ -176,6 +177,7 @@ run <- function( assert_that(is.null(stdout_callback) || is.function(stdout_callback)) assert_that(is.null(stderr_callback) || is.function(stderr_callback)) assert_that(is_flag(cleanup_tree)) + assert_that(is_numeric_scalar(cleanup_grace)) assert_that(is_flag(stderr_to_stdout)) ## The rest is checked by process$new() "!DEBUG run() Checked arguments" @@ -195,9 +197,9 @@ run <- function( ## We make sure that the process is eliminated if (cleanup_tree) { - on.exit(pr$kill_tree(), add = TRUE) + defer(pr$kill_tree(grace = cleanup_grace)) } else { - on.exit(pr$kill(), add = TRUE) + defer(pr$kill(grace = cleanup_grace)) } ## If echo, then we need to create our own callbacks. @@ -238,7 +240,7 @@ run <- function( resenv$errbuf$push(pr$read_error()) resenv$errbuf$read() } - tryCatch(pr$kill(), error = function(e) NULL) + tryCatch(pr$kill(grace = cleanup_grace), error = function(e) NULL) signalCondition(new_process_interrupt_cond( list( interrupt = TRUE, stderr = err, stdout = out, diff --git a/man/run.Rd b/man/run.Rd index 4b6c407c..feb58430 100644 --- a/man/run.Rd +++ b/man/run.Rd @@ -25,6 +25,7 @@ run( windows_hide_window = FALSE, encoding = "", cleanup_tree = FALSE, + cleanup_grace = 0.1, ... ) } @@ -128,6 +129,8 @@ both streams in UTF-8 currently.} \item{cleanup_tree}{Whether to clean up the child process tree after the process has finished.} +\item{cleanup_grace}{Passed to \code{kill()} or \code{kill_tree()} on cleanup.} + \item{...}{Extra arguments are passed to \code{process$new()}, see \link{process}. Note that you cannot pass \code{stout} or \code{stderr} here, because they are used internally by \code{run()}. You can use the From 11d37ca782a174f0210adacf89c5abea2b918868 Mon Sep 17 00:00:00 2001 From: Lionel Henry Date: Wed, 19 Apr 2023 19:07:08 +0200 Subject: [PATCH 04/21] Add `cleanup_grace` argument to process ctor --- R/assertions.R | 4 ++ R/initialize.R | 12 +++--- R/process.R | 21 +++++++---- man/process.Rd | 6 +++ man/process_initialize.Rd | 1 + src/init.c | 2 +- src/processx.h | 4 +- src/unix/processx-unix.h | 3 ++ src/unix/processx.c | 69 ++++++++++++++++++----------------- src/win/processx.c | 4 +- tests/testthat/test-process.R | 36 ++++++++++++++++++ 11 files changed, 111 insertions(+), 51 deletions(-) diff --git a/R/assertions.R b/R/assertions.R index e78d3c67..746bf69f 100644 --- a/R/assertions.R +++ b/R/assertions.R @@ -31,6 +31,10 @@ is_numeric_scalar <- function(x) { is.numeric(x) && length(x) == 1 && !is.na(x) } +on_failure(is_numeric_scalar) <- function(call, env) { + paste0(deparse(call$x), " is not a length 1 number") +} + is_integerish_scalar <- function(x) { is.numeric(x) && length(x) == 1 && !is.na(x) && round(x) == x } diff --git a/R/initialize.R b/R/initialize.R index 3d7b6d4a..673c97c3 100644 --- a/R/initialize.R +++ b/R/initialize.R @@ -25,10 +25,10 @@ process_initialize <- function(self, private, command, args, stdin, stdout, stderr, pty, pty_options, connections, poll_connection, env, cleanup, - cleanup_tree, wd, echo_cmd, supervise, - windows_verbatim_args, windows_hide_window, - windows_detached_process, encoding, - post_process) { + cleanup_tree, cleanup_grace, wd, echo_cmd, + supervise, windows_verbatim_args, + windows_hide_window, windows_detached_process, + encoding, post_process) { "!DEBUG process_initialize `command`" @@ -45,6 +45,7 @@ process_initialize <- function(self, private, command, args, is.null(env) || is_env_vector(env), is_flag(cleanup), is_flag(cleanup_tree), + is_numeric_scalar(cleanup_grace), is_string_or_null(wd), is_flag(echo_cmd), is_flag(windows_verbatim_args), @@ -99,6 +100,7 @@ process_initialize <- function(self, private, command, args, private$args <- args private$cleanup <- cleanup private$cleanup_tree <- cleanup_tree + private$cleanup_grace <- cleanup_grace private$wd <- wd private$pstdin <- stdin private$pstdout <- stdout @@ -139,7 +141,7 @@ process_initialize <- function(self, private, command, args, c_processx_exec, command, c(command, args), pty, pty_options, connections, env, windows_verbatim_args, windows_hide_window, - windows_detached_process, private, cleanup, wd, encoding, + windows_detached_process, private, cleanup, cleanup_grace, wd, encoding, paste0("PROCESSX_", private$tree_id, "=YES") ) diff --git a/R/process.R b/R/process.R index eefd5393..ef1f3dc1 100644 --- a/R/process.R +++ b/R/process.R @@ -184,6 +184,10 @@ process <- R6::R6Class( #' object is garbage collected. #' @param cleanup_tree Whether to kill the process and its child #' process tree when the `process` object is garbage collected. + #' @param cleanup_grace Grace period between `SIGTERM` and `SIGKILL`. + #' Only has an effect on Unix platforms. Set to 0 to terminate abruptly + #' with `SIGKILL` only. Currently defaults to 0 until we implement + #' a better approach on session quit. #' @param wd Working directory of the process. It must exist. #' If `NULL`, then the current working directory is used. #' @param echo_cmd Whether to print the command to the screen before @@ -212,17 +216,17 @@ process <- R6::R6Class( initialize = function(command = NULL, args = character(), stdin = NULL, stdout = NULL, stderr = NULL, pty = FALSE, pty_options = list(), connections = list(), poll_connection = NULL, - env = NULL, cleanup = TRUE, cleanup_tree = FALSE, wd = NULL, - echo_cmd = FALSE, supervise = FALSE, windows_verbatim_args = FALSE, - windows_hide_window = FALSE, windows_detached_process = !cleanup, - encoding = "", post_process = NULL) + env = NULL, cleanup = TRUE, cleanup_tree = FALSE, cleanup_grace = 0.0, + wd = NULL, echo_cmd = FALSE, supervise = FALSE, + windows_verbatim_args = FALSE, windows_hide_window = FALSE, + windows_detached_process = !cleanup, encoding = "", post_process = NULL) process_initialize(self, private, command, args, stdin, stdout, stderr, pty, pty_options, connections, - poll_connection, env, cleanup, cleanup_tree, wd, - echo_cmd, supervise, windows_verbatim_args, - windows_hide_window, windows_detached_process, - encoding, post_process), + poll_connection, env, cleanup, cleanup_tree, + cleanup_grace, wd, echo_cmd, supervise, + windows_verbatim_args, windows_hide_window, + windows_detached_process, encoding, post_process), #' @description #' Cleanup method that is called when the `process` object is garbage @@ -650,6 +654,7 @@ process <- R6::R6Class( args = NULL, # Save 'args' argument here cleanup = NULL, # cleanup argument cleanup_tree = NULL, # cleanup_tree argument + cleanup_grace = NULL, # cleanup_grace argument stdin = NULL, # stdin argument or stream stdout = NULL, # stdout argument or stream stderr = NULL, # stderr argument or stream diff --git a/man/process.Rd b/man/process.Rd index a6330438..db35b11e 100644 --- a/man/process.Rd +++ b/man/process.Rd @@ -161,6 +161,7 @@ Start a new process in the background, and then return immediately. env = NULL, cleanup = TRUE, cleanup_tree = FALSE, + cleanup_grace = 0, wd = NULL, echo_cmd = FALSE, supervise = FALSE, @@ -275,6 +276,11 @@ object is garbage collected.} \item{\code{cleanup_tree}}{Whether to kill the process and its child process tree when the \code{process} object is garbage collected.} +\item{\code{cleanup_grace}}{Grace period between \code{SIGTERM} and \code{SIGKILL}. +Only has an effect on Unix platforms. Set to 0 to terminate abruptly +with \code{SIGKILL} only. Currently defaults to 0 until we implement +a better approach on session quit.} + \item{\code{wd}}{Working directory of the process. It must exist. If \code{NULL}, then the current working directory is used.} diff --git a/man/process_initialize.Rd b/man/process_initialize.Rd index e4dbbafb..0db37b60 100644 --- a/man/process_initialize.Rd +++ b/man/process_initialize.Rd @@ -19,6 +19,7 @@ process_initialize( env, cleanup, cleanup_tree, + cleanup_grace, wd, echo_cmd, supervise, diff --git a/src/init.c b/src/init.c index a784d8a2..32f47aa3 100644 --- a/src/init.c +++ b/src/init.c @@ -15,7 +15,7 @@ SEXP processx__set_boot_time(SEXP); static const R_CallMethodDef callMethods[] = { CLEANCALL_METHOD_RECORD, - { "processx_exec", (DL_FUNC) &processx_exec, 14 }, + { "processx_exec", (DL_FUNC) &processx_exec, 15 }, { "processx_wait", (DL_FUNC) &processx_wait, 3 }, { "processx_is_alive", (DL_FUNC) &processx_is_alive, 2 }, { "processx_get_exit_status", (DL_FUNC) &processx_get_exit_status, 2 }, diff --git a/src/processx.h b/src/processx.h index 9e3d5eee..28272171 100644 --- a/src/processx.h +++ b/src/processx.h @@ -45,8 +45,8 @@ extern "C" { SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, SEXP connections, SEXP env, SEXP windows_verbatim_args, SEXP windows_hide_window, SEXP windows_detached_process, - SEXP private_, SEXP cleanup, SEXP wd, SEXP encoding, - SEXP tree_id); + SEXP private_, SEXP cleanup, SEXP cleanup_signal, + SEXP wd, SEXP encoding, SEXP tree_id); SEXP processx_wait(SEXP status, SEXP timeout, SEXP name); SEXP processx_is_alive(SEXP status, SEXP name); SEXP processx_get_exit_status(SEXP status, SEXP name); diff --git a/src/unix/processx-unix.h b/src/unix/processx-unix.h index 1f97bf40..fa481051 100644 --- a/src/unix/processx-unix.h +++ b/src/unix/processx-unix.h @@ -23,6 +23,8 @@ typedef struct processx_handle_s { int fd2; /* readable */ int waitpipe[2]; /* use it for wait() with timeout */ int cleanup; + int cleanup_signal; + double cleanup_grace; double create_time; processx_connection_t *pipes[3]; int ptyfd; @@ -41,6 +43,7 @@ void processx__unblock_sigchld(void); void processx__procmask_set(sigset_t *set); int c_processx_wait(processx_handle_t *handle, int timeout, const char *name); +int c_processx_kill(SEXP status, double grace, SEXP name); void processx__finalizer(SEXP status); diff --git a/src/unix/processx.c b/src/unix/processx.c index 7be6a810..cb8df2b3 100644 --- a/src/unix/processx.c +++ b/src/unix/processx.c @@ -21,7 +21,7 @@ static void processx__child_init(processx_handle_t *handle, SEXP connections, processx_options_t *options, const char *tree_id); -static SEXP processx__make_handle(SEXP private, int cleanup); +static SEXP processx__make_handle(SEXP private, int cleanup, double cleanup_grace); static void processx__handle_destroy(processx_handle_t *handle); void processx__create_connections(processx_handle_t *handle, SEXP private, const char *encoding); @@ -324,38 +324,38 @@ static void processx__child_init(processx_handle_t *handle, SEXP connections, /* LCOV_EXCL_STOP */ +struct cleanup_kill_data { + SEXP status; + double grace; + SEXP name; +}; + +SEXP c_processx_kill_data(void *payload) { + struct cleanup_kill_data *data = (struct cleanup_kill_data *) payload; + c_processx_kill(data->status, data->grace, data->name); + return R_NilValue; +} + void processx__finalizer(SEXP status) { processx_handle_t *handle = (processx_handle_t*) R_ExternalPtrAddr(status); - pid_t pid; - int wp, wstat; - - processx__block_sigchld(); /* Free child list nodes that are not needed any more. */ + processx__block_sigchld(); processx__freelist_free(); + processx__unblock_sigchld(); /* Already freed? */ - if (!handle) goto cleanup; - - pid = handle->pid; + if (!handle) + return; + // FIXME: Do we need cleancall here? if (handle->cleanup) { - /* Do a non-blocking waitpid() to see if it is running */ - do { - wp = waitpid(pid, &wstat, WNOHANG); - } while (wp == -1 && errno == EINTR); - - /* Maybe just waited on it? Then collect status */ - if (wp == pid) processx__collect_exit_status(status, wp, wstat); - - /* If it is running, we need to kill it, and wait for the exit status */ - if (wp == 0) { - kill(-pid, SIGKILL); - do { - wp = waitpid(pid, &wstat, 0); - } while (wp == -1 && errno == EINTR); - processx__collect_exit_status(status, wp, wstat); - } + struct cleanup_kill_data data = { + .status = status, + .grace = handle->cleanup_grace, + .name = R_NilValue + }; + r_with_cleanup_context(c_processx_kill_data, &data); } /* Note: if no cleanup is requested, then we still have a sigchld @@ -365,12 +365,9 @@ void processx__finalizer(SEXP status) { /* Deallocate memory */ R_ClearExternalPtr(status); processx__handle_destroy(handle); - - cleanup: - processx__unblock_sigchld(); } -static SEXP processx__make_handle(SEXP private, int cleanup) { +static SEXP processx__make_handle(SEXP private, int cleanup, double cleanup_grace) { processx_handle_t * handle; SEXP result; @@ -382,6 +379,7 @@ static SEXP processx__make_handle(SEXP private, int cleanup) { result = PROTECT(R_MakeExternalPtr(handle, private, R_NilValue)); R_RegisterCFinalizerEx(result, processx__finalizer, 1); handle->cleanup = cleanup; + handle->cleanup_grace = cleanup_grace; UNPROTECT(1); return result; @@ -428,13 +426,14 @@ void processx__make_socketpair(int pipe[2], const char *exe) { SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, SEXP connections, SEXP env, SEXP windows_verbatim_args, SEXP windows_hide_window, SEXP windows_detached_process, - SEXP private, SEXP cleanup, SEXP wd, SEXP encoding, - SEXP tree_id) { + SEXP private, SEXP cleanup, SEXP cleanup_grace, SEXP wd, + SEXP encoding, SEXP tree_id) { char *ccommand = processx__tmp_string(command, 0); char **cargs = processx__tmp_character(args); char **cenv = isNull(env) ? 0 : processx__tmp_character(env); int ccleanup = INTEGER(cleanup)[0]; + double ccleanup_grace = REAL(cleanup_grace)[0]; const int cpty = LOGICAL(pty)[0]; const char *cencoding = CHAR(STRING_ELT(encoding, 0)); @@ -469,7 +468,7 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, processx__setup_sigchld(); - result = PROTECT(processx__make_handle(private, ccleanup)); + result = PROTECT(processx__make_handle(private, ccleanup, ccleanup_grace)); handle = R_ExternalPtrAddr(result); if (cpty) { @@ -973,6 +972,10 @@ SEXP processx_interrupt(SEXP status, SEXP name) { */ SEXP processx_kill(SEXP status, SEXP grace, SEXP name) { + return ScalarLogical(c_processx_kill(status, REAL(grace)[0], name)); +} + +int c_processx_kill(SEXP status, double grace, SEXP name) { processx_handle_t *handle = R_ExternalPtrAddr(status); const char *cname = isNull(name) ? "???" : CHAR(STRING_ELT(name, 0)); pid_t pid; @@ -1008,7 +1011,7 @@ SEXP processx_kill(SEXP status, SEXP grace, SEXP name) { if (wp != 0) { goto cleanup; } /* It is still running, send a SIGTERM if gracious */ - double grace_ms = REAL(grace)[0] * 1000; + double grace_ms = grace * 1000; #define KILL_WITH(SIG) do { \ int ret = kill(-pid, SIG); \ @@ -1045,7 +1048,7 @@ SEXP processx_kill(SEXP status, SEXP grace, SEXP name) { cleanup: processx__unblock_sigchld(); - return ScalarLogical(result); + return result; } SEXP processx_get_pid(SEXP status) { diff --git a/src/win/processx.c b/src/win/processx.c index 20010e4e..c423438d 100644 --- a/src/win/processx.c +++ b/src/win/processx.c @@ -868,8 +868,8 @@ void processx__handle_destroy(processx_handle_t *handle) { SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, SEXP connections, SEXP env, SEXP windows_verbatim_args, SEXP windows_hide, SEXP windows_detached_process, - SEXP private, SEXP cleanup, SEXP wd, SEXP encoding, - SEXP tree_id) { + SEXP private, SEXP cleanup, SEXP _cleanup_grace, SEXP wd, + SEXP encoding, SEXP tree_id) { const char *ccommand = CHAR(STRING_ELT(command, 0)); const char *cencoding = CHAR(STRING_ELT(encoding, 0)); diff --git a/tests/testthat/test-process.R b/tests/testthat/test-process.R index 0e202e3d..ff67464a 100644 --- a/tests/testthat/test-process.R +++ b/tests/testthat/test-process.R @@ -266,6 +266,42 @@ test_that("can kill process with grace", { expect_true(dir.exists(dir)) }) +test_that("can use custom `cleanup_signal`", { + # https://github.com/r-lib/callr/pull/250 + skip_if_not_installed("callr", "3.7.3.9001") + + withr::local_envvar("PROCESSX_R_SIGTERM_CLEANUP" = "true") + + # Should become the default in callr + opts <- callr::r_process_options(extra = list( + cleanup_grace = 0.1 + )) + p <- callr::r_session$new(opts) + + out <- tempfile() + defer(rimraf(out)) + + fn <- function(file) { + file.create(tempfile()) + writeLines(tempdir(), file) + } + p$run(fn, list(file = out)) + + dir <- readLines(out) + defer(rimraf(dir)) + + # GC `p` to trigger finalizer + rm(p) + gc() + + # Needs POSIX signals + skip_on_os("windows") + + # As usual we verify the delivery of SIGTERM by checking that the + # callr cleanup handler kicked in and deleted the tempdir + expect_false(dir.exists(dir)) +}) + test_that("can load sigtermignore", { p <- callr::r_session$new() defer(p$kill()) From e83a48c338c54a0511d982c49f0c26f2ded708a4 Mon Sep 17 00:00:00 2001 From: Lionel Henry Date: Thu, 27 Apr 2023 13:20:20 +0200 Subject: [PATCH 05/21] Use `cleanup_grace` in finaliser --- R/process.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/process.R b/R/process.R index ef1f3dc1..94382ab6 100644 --- a/R/process.R +++ b/R/process.R @@ -235,7 +235,7 @@ process <- R6::R6Class( finalize = function() { if (!is.null(private$tree_id) && private$cleanup_tree && - ps::ps_is_supported()) self$kill_tree() + ps::ps_is_supported()) self$kill_tree(grace = private$cleanup_grace) }, #' @description From b586b125885bd39a190cfc8846ca40c307533c29 Mon Sep 17 00:00:00 2001 From: Lionel Henry Date: Thu, 27 Apr 2023 13:31:40 +0200 Subject: [PATCH 06/21] Poll for completion of cleanup process in unit test --- tests/testthat/test-process.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testthat/test-process.R b/tests/testthat/test-process.R index ff67464a..4bf8f219 100644 --- a/tests/testthat/test-process.R +++ b/tests/testthat/test-process.R @@ -256,7 +256,7 @@ test_that("can kill process with grace", { p$run(fn, list(file = out)) dir <- get_temp_dir() p$kill(grace = 0.1) - expect_false(dir.exists(dir)) + poll_until(function() !dir.exists(dir)) # When `grace` is 0, the tempdir isn't cleaned up p <- callr::r_session$new() From b693822cb80510cb4645421fd00ebe4725f4b7f8 Mon Sep 17 00:00:00 2001 From: Lionel Henry Date: Thu, 27 Apr 2023 13:34:28 +0200 Subject: [PATCH 07/21] Rbuildignore test library --- .Rbuildignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.Rbuildignore b/.Rbuildignore index 7f111acd..83e08546 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -11,6 +11,8 @@ ^src/processx\.dll$ ^src/client\.so$ ^src/client\.dll$ +^src/test/sigtermignore\.so$ +^src/test/sigtermignore\.dll$ ^src/.*\.o$ ^src/tools/px$ ^src/tools/px.exe$ From 86528ecd58c13517b4318912fb1ab84c9ef3d4f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Sat, 26 Apr 2025 16:56:12 +0200 Subject: [PATCH 08/21] Code formatting --- tests/testthat/test-process.R | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/testthat/test-process.R b/tests/testthat/test-process.R index d6ce1156..92dd92f8 100644 --- a/tests/testthat/test-process.R +++ b/tests/testthat/test-process.R @@ -288,9 +288,11 @@ test_that("can use custom `cleanup_signal`", { withr::local_envvar("PROCESSX_R_SIGTERM_CLEANUP" = "true") # Should become the default in callr - opts <- callr::r_process_options(extra = list( - cleanup_grace = 0.1 - )) + opts <- callr::r_process_options( + extra = list( + cleanup_grace = 0.1 + ) + ) p <- callr::r_session$new(opts) out <- tempfile() From 200d27c09901c291f7a33f7fa0aa418a392de33c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Sat, 26 Apr 2025 17:05:34 +0200 Subject: [PATCH 09/21] Adjust Linux test snapshots --- tests/testthat/_snaps/Linux/process.md | 4 ++-- tests/testthat/_snaps/Linux/run.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/testthat/_snaps/Linux/process.md b/tests/testthat/_snaps/Linux/process.md index b8131c65..1477629c 100644 --- a/tests/testthat/_snaps/Linux/process.md +++ b/tests/testthat/_snaps/Linux/process.md @@ -6,7 +6,7 @@ Error: ! Native call to `processx_exec` failed Caused by error: - ! cannot start processx process '/' (system error 2, No such file or directory) @unix/processx.c:611 (processx_exec) + ! cannot start processx process '/' (system error 2, No such file or directory) @unix/processx.c:610 (processx_exec) # working directory does not exist @@ -16,5 +16,5 @@ Error: ! Native call to `processx_exec` failed Caused by error: - ! cannot start processx process '/px' (system error 2, No such file or directory) @unix/processx.c:611 (processx_exec) + ! cannot start processx process '/px' (system error 2, No such file or directory) @unix/processx.c:610 (processx_exec) diff --git a/tests/testthat/_snaps/Linux/run.md b/tests/testthat/_snaps/Linux/run.md index 3364673f..d828f68c 100644 --- a/tests/testthat/_snaps/Linux/run.md +++ b/tests/testthat/_snaps/Linux/run.md @@ -6,5 +6,5 @@ Error: ! Native call to `processx_exec` failed Caused by error: - ! cannot start processx process '/px' (system error 2, No such file or directory) @unix/processx.c:611 (processx_exec) + ! cannot start processx process '/px' (system error 2, No such file or directory) @unix/processx.c:610 (processx_exec) From 3291027d471672d5ffbf620c0a1c512770792650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Thu, 23 Apr 2026 12:24:18 +0200 Subject: [PATCH 10/21] Explicit cast to int for cleanup grace --- src/unix/processx.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/unix/processx.c b/src/unix/processx.c index 2e7d434c..5258979d 100644 --- a/src/unix/processx.c +++ b/src/unix/processx.c @@ -1073,7 +1073,7 @@ int c_processx_kill(SEXP status, double grace, SEXP name) { if (wp != 0) { goto cleanup; } /* It is still running, send a SIGTERM if gracious */ - double grace_ms = grace * 1000; + int grace_ms = (int)(grace * 1000); #define KILL_WITH(SIG) do { \ int ret = kill(-pid, SIG); \ From 56b45928ae01d56d89114ce480c5d670be1c831f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Thu, 23 Apr 2026 12:25:47 +0200 Subject: [PATCH 11/21] Remove unused field from internal struct --- src/unix/processx-unix.h | 1 - 1 file changed, 1 deletion(-) diff --git a/src/unix/processx-unix.h b/src/unix/processx-unix.h index fadcb6b6..504b4c1a 100644 --- a/src/unix/processx-unix.h +++ b/src/unix/processx-unix.h @@ -23,7 +23,6 @@ typedef struct processx_handle_s { int fd2; /* readable */ int waitpipe[2]; /* use it for wait() with timeout */ int cleanup; - int cleanup_signal; double cleanup_grace; double create_time; double end_time; /* 0.0 until the process exits */ From 13ea845842f36682ba0ed9a6642bb44b4d0663f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Thu, 23 Apr 2026 12:26:31 +0200 Subject: [PATCH 12/21] Correct argument name in .h file --- src/processx.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/processx.h b/src/processx.h index d21b7984..79acedbb 100644 --- a/src/processx.h +++ b/src/processx.h @@ -45,8 +45,8 @@ extern "C" { SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, SEXP connections, SEXP env, SEXP windows_verbatim_args, SEXP windows_hide_window, SEXP windows_detached_process, - SEXP private_, SEXP cleanup, SEXP cleanup_signal, - SEXP wd, SEXP encoding, SEXP tree_id, SEXP linux_pdeathsig); + SEXP private_, SEXP cleanup, SEXP cleanup_grace, + SEXP wd, SEXP encoding, SEXP tree_id, SEXP linux_pdeathsig); SEXP processx_wait(SEXP status, SEXP timeout, SEXP name); SEXP processx_is_alive(SEXP status, SEXP name); SEXP processx_pty_close(SEXP status, SEXP name); From 21dcefddf8757ca2225c4454f0ea5e51f2b2c866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Thu, 23 Apr 2026 12:27:05 +0200 Subject: [PATCH 13/21] NEWS.md for cleanup_grace --- NEWS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/NEWS.md b/NEWS.md index eabb16c0..39f2e0a2 100644 --- a/NEWS.md +++ b/NEWS.md @@ -5,6 +5,11 @@ timeout of `grace` seconds. After the timeout, `SIGKILL` is sent as before. +* New `cleanup_grace` argument to `process$new()` and `run()`. When the + process is cleaned up (on GC or on exit), processx first tries + `SIGTERM` with a timeout of `cleanup_grace` seconds before falling + back to `SIGKILL`. On Unix only. + # processx 3.9.0 * New experimental `pipeline` R6 class for running two or more processes From 4814e2c5759120099e6e345e91cc2f47d5e7a59b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Thu, 23 Apr 2026 12:27:54 +0200 Subject: [PATCH 14/21] Better validation for cleanup grace --- R/assertions.R | 8 ++++---- R/initialize.R | 2 +- R/run.R | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/R/assertions.R b/R/assertions.R index e4d8ea96..8d5d6568 100644 --- a/R/assertions.R +++ b/R/assertions.R @@ -26,12 +26,12 @@ on_failure(is_flag) <- function(call, env) { paste0(deparse(call$x), " is not a flag (length 1 logical)") } -is_numeric_scalar <- function(x) { - is.numeric(x) && length(x) == 1 && !is.na(x) +is_nonneg_numeric_scalar <- function(x) { + is.numeric(x) && length(x) == 1 && !is.na(x) && x >= 0 } -on_failure(is_numeric_scalar) <- function(call, env) { - paste0(deparse(call$x), " is not a length 1 number") +on_failure(is_nonneg_numeric_scalar) <- function(call, env) { + paste0(deparse(call$x), " is not a non-negative length 1 number") } is_integerish_scalar <- function(x) { diff --git a/R/initialize.R b/R/initialize.R index f0158cb5..30ddaa0a 100644 --- a/R/initialize.R +++ b/R/initialize.R @@ -63,7 +63,7 @@ process_initialize <- function( is.null(env) || is_env_vector(env), is_flag(cleanup), is_flag(cleanup_tree), - is_numeric_scalar(cleanup_grace), + is_nonneg_numeric_scalar(cleanup_grace), is_string_or_null(wd), is_flag(echo_cmd), is_flag(windows_verbatim_args), diff --git a/R/run.R b/R/run.R index 4c1e4543..5e401da0 100644 --- a/R/run.R +++ b/R/run.R @@ -217,7 +217,7 @@ run <- function( assert_that(is.null(stdout_callback) || is.function(stdout_callback)) assert_that(is.null(stderr_callback) || is.function(stderr_callback)) assert_that(is_flag(cleanup_tree)) - assert_that(is_numeric_scalar(cleanup_grace)) + assert_that(is_nonneg_numeric_scalar(cleanup_grace)) assert_that(is_flag(stderr_to_stdout)) if (encoding == "binary") { if (!is.null(stdout_line_callback)) { From 7030018155201ee6fdb67bbfe581a25193449af9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Thu, 23 Apr 2026 12:28:16 +0200 Subject: [PATCH 15/21] Adjust required callr version in tests --- tests/testthat/test-process.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testthat/test-process.R b/tests/testthat/test-process.R index 00a63746..30ad22ff 100644 --- a/tests/testthat/test-process.R +++ b/tests/testthat/test-process.R @@ -379,7 +379,7 @@ test_that("can kill process with grace", { test_that("can use custom `cleanup_signal`", { # https://github.com/r-lib/callr/pull/250 - skip_if_not_installed("callr", "3.7.3.9001") + skip_if_not_installed("callr", "3.7.4") withr::local_envvar("PROCESSX_R_SIGTERM_CLEANUP" = "true") From f81cef9aeedc388e68697a817b21ecbf5af9e5a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Thu, 23 Apr 2026 12:28:44 +0200 Subject: [PATCH 16/21] Make a cleanup test more robust A single gc() might not trigger the finalizer. --- tests/testthat/test-process.R | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/testthat/test-process.R b/tests/testthat/test-process.R index 30ad22ff..a8e2f3ef 100644 --- a/tests/testthat/test-process.R +++ b/tests/testthat/test-process.R @@ -403,16 +403,16 @@ test_that("can use custom `cleanup_signal`", { dir <- readLines(out) defer(rimraf(dir)) - # GC `p` to trigger finalizer - rm(p) - gc() - # Needs POSIX signals skip_on_os("windows") - # As usual we verify the delivery of SIGTERM by checking that the - # callr cleanup handler kicked in and deleted the tempdir - expect_false(dir.exists(dir)) + # GC `p` to trigger finalizer; R doesn't guarantee finalizers run on a + # single gc(), so we retry until the side-effect is observed + rm(p) + retry_until(function() { + gc() + !dir.exists(dir) + }) }) test_that("can load sigtermignore", { From 0b9f84390fe0b9e4ad5816c661a2e241a427eb1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Thu, 23 Apr 2026 12:39:49 +0200 Subject: [PATCH 17/21] Fix compilation on Windows --- src/win/processx-win.h | 1 + src/win/processx.c | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/win/processx-win.h b/src/win/processx-win.h index b533c9be..d60179ab 100644 --- a/src/win/processx-win.h +++ b/src/win/processx-win.h @@ -13,6 +13,7 @@ typedef struct processx_handle_s { HANDLE waitObject; processx_connection_t *pipes[3]; int cleanup; + double cleanup_grace; double create_time; double end_time; /* 0.0 until the process exits */ void *ptycon; /* ConPTY handle (HPCON), NULL if not using PTY */ diff --git a/src/win/processx.c b/src/win/processx.c index 62433d9e..8a865492 100644 --- a/src/win/processx.c +++ b/src/win/processx.c @@ -922,7 +922,7 @@ void processx__finalizer(SEXP status) { processx__handle_destroy(handle); } -SEXP processx__make_handle(SEXP private, int cleanup) { +SEXP processx__make_handle(SEXP private, int cleanup, double cleanup_grace) { processx_handle_t * handle; SEXP result; @@ -933,6 +933,7 @@ SEXP processx__make_handle(SEXP private, int cleanup) { result = PROTECT(R_MakeExternalPtr(handle, private, R_NilValue)); R_RegisterCFinalizerEx(result, processx__finalizer, 1); handle->cleanup = cleanup; + handle->cleanup_grace = cleanup_grace; UNPROTECT(1); return result; @@ -951,8 +952,8 @@ void processx__handle_destroy(processx_handle_t *handle) { SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, SEXP connections, SEXP env, SEXP windows_verbatim_args, SEXP windows_hide, SEXP windows_detached_process, - SEXP private, SEXP cleanup, SEXP wd, SEXP encoding, - SEXP tree_id, SEXP linux_pdeathsig) { + SEXP private, SEXP cleanup, SEXP cleanup_grace, SEXP wd, + SEXP encoding, SEXP tree_id, SEXP linux_pdeathsig) { const char *ccommand = CHAR(STRING_ELT(command, 0)); const char *cencoding = CHAR(STRING_ELT(encoding, 0)); @@ -971,6 +972,7 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, processx_handle_t *handle; int ccleanup = INTEGER(cleanup)[0]; + double ccleanup_grace = REAL(cleanup_grace)[0]; SEXP result; DWORD dwerr; @@ -1038,7 +1040,7 @@ SEXP processx_exec(SEXP command, SEXP args, SEXP pty, SEXP pty_options, } } - result = PROTECT(processx__make_handle(private, ccleanup)); + result = PROTECT(processx__make_handle(private, ccleanup, ccleanup_grace)); handle = R_ExternalPtrAddr(result); application_path = processx__search_path(application, cwd, path); From 5d7776dce34d80444bf832d9ae789e8daf7b1873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Thu, 23 Apr 2026 12:53:23 +0200 Subject: [PATCH 18/21] Update Windows test snapshots --- tests/testthat/_snaps/Windows/process.md | 4 ++-- tests/testthat/_snaps/Windows/run.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/testthat/_snaps/Windows/process.md b/tests/testthat/_snaps/Windows/process.md index ed1e8608..ce3bad80 100644 --- a/tests/testthat/_snaps/Windows/process.md +++ b/tests/testthat/_snaps/Windows/process.md @@ -6,7 +6,7 @@ Error in `process_initialize()`: ! ! Native call to `processx_exec` failed Caused by error in `chain_call(...)` at initialize.R::: - ! Command '/' not found @win/processx.c:1059 (processx_exec) + ! Command '/' not found @win/processx.c:1061 (processx_exec) # working directory does not exist @@ -17,5 +17,5 @@ ! ! Native call to `processx_exec` failed Caused by error in `chain_call(...)` at initialize.R::: ! create process '/px' (system error 267, The directory name is invalid. - ) @win/processx.c:1370 (processx_exec) + ) @win/processx.c:1372 (processx_exec) diff --git a/tests/testthat/_snaps/Windows/run.md b/tests/testthat/_snaps/Windows/run.md index 5dc6efa8..20c7fd31 100644 --- a/tests/testthat/_snaps/Windows/run.md +++ b/tests/testthat/_snaps/Windows/run.md @@ -7,5 +7,5 @@ ! ! Native call to `processx_exec` failed Caused by error in `chain_call(...)` at initialize.R::: ! create process '/px' (system error 267, The directory name is invalid. - ) @win/processx.c:1370 (processx_exec) + ) @win/processx.c:1372 (processx_exec) From 60e68c6548784da68ff060afd032463a73520733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Thu, 23 Apr 2026 12:53:32 +0200 Subject: [PATCH 19/21] Skip grace tests on Windows Does not work on Windows. --- tests/testthat/test-process.R | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/testthat/test-process.R b/tests/testthat/test-process.R index a8e2f3ef..4fd95346 100644 --- a/tests/testthat/test-process.R +++ b/tests/testthat/test-process.R @@ -342,6 +342,7 @@ test_that("get_end_time", { test_that("can kill process with grace", { # https://github.com/r-lib/callr/pull/250 + skip_on_os("windows") skip_if_not_installed("callr", "3.7.3.9001") withr::local_envvar("PROCESSX_R_SIGTERM_CLEANUP" = "true") @@ -416,6 +417,7 @@ test_that("can use custom `cleanup_signal`", { }) test_that("can load sigtermignore", { + skip_on_os("windows") p <- callr::r_session$new() defer(p$kill()) @@ -428,6 +430,7 @@ test_that("can load sigtermignore", { }) test_that("can kill with SIGTERM when ignored", { + skip_on_os("windows") p <- callr::r_session$new() defer(p$kill()) From cfe5edde2857c8efe21705e95b5bf3487581051f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Thu, 23 Apr 2026 14:12:38 +0200 Subject: [PATCH 20/21] Do not install test list into libs/test Because R CMD INSTALL assumes that subdirectories within libs correspond to sub-architectures, and e.g. R CMD INSTALL --libs-only fails. --- R/utils.R | 1 - src/Makevars | 6 +++--- src/install.libs.R | 5 ----- src/test/sigtermignore.c | 7 +++++++ 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/R/utils.R b/R/utils.R index bc33246a..299fdc86 100644 --- a/R/utils.R +++ b/R/utils.R @@ -373,7 +373,6 @@ get_test_lib <- function(lib) { system.file( package = "processx", path, - "test", paste0(lib, .Platform$dynlib.ext) ) } diff --git a/src/Makevars b/src/Makevars index b3945aea..3d1a031b 100644 --- a/src/Makevars +++ b/src/Makevars @@ -8,7 +8,7 @@ OBJECTS = init.o poll.o errors.o processx-connection.o \ all: tools/px tools/sock supervisor/supervisor client$(SHLIB_EXT) $(SHLIB) strip -strip: $(SHLIB) tools/px tools/sock supervisor/supervisor client$(SHLIB_EXT) test/sigtermignore$(SHLIB_EXT) +strip: $(SHLIB) tools/px tools/sock supervisor/supervisor client$(SHLIB_EXT) sigtermignore$(SHLIB_EXT) @if which strip >/dev/null && which uname >/dev/null && test "`uname`" = "Linux" && test "$$_R_SHLIB_STRIP_" = "true" && test -n "$$R_STRIP_SHARED_LIB"; then \ echo stripping $(SHLIB) tools/px tools/sock supervisor/supervisor client$(SHLIB_EXT); \ echo $$R_STRIP_SHARED_LIB $(SHLIB) tools/px tools/sock supervisor/supervisor client$(SHLIB_EXT); \ @@ -37,8 +37,8 @@ client$(SHLIB_EXT): $(CLIENT_OBJECTS) patchelf --remove-needed libR.so client$(SHLIB_EXT); \ fi -test/sigtermignore$(SHLIB_EXT): test/sigtermignore.o - $(SHLIB_LINK) -o test/sigtermignore$(SHLIB_EXT) test/sigtermignore.o +sigtermignore$(SHLIB_EXT): test/sigtermignore.o + $(SHLIB_LINK) -o sigtermignore$(SHLIB_EXT) test/sigtermignore.o clean: rm -rf $(SHLIB) $(OBJECTS) $(CLIENT_OBJECTS) \ diff --git a/src/install.libs.R b/src/install.libs.R index 357e22c1..59a1cd79 100644 --- a/src/install.libs.R +++ b/src/install.libs.R @@ -18,8 +18,3 @@ file.copy(files, dest, overwrite = TRUE) if (file.exists("symbols.rds")) { file.copy("symbols.rds", dest, overwrite = TRUE) } - -test_files <- Sys.glob(paste0("test/*", SHLIB_EXT)) -test_dest <- file.path(dest, "test") -dir.create(test_dest, recursive = TRUE, showWarnings = FALSE) -file.copy(test_files, test_dest, overwrite = TRUE) diff --git a/src/test/sigtermignore.c b/src/test/sigtermignore.c index b564fbfd..168006eb 100644 --- a/src/test/sigtermignore.c +++ b/src/test/sigtermignore.c @@ -8,4 +8,11 @@ void R_init_sigtermignore(DllInfo *dll) { signal(SIGTERM, SIG_IGN); } +// work around R CMD check false positive + +void dummy() { + R_registerRoutines(NULL, NULL, NULL, NULL, NULL); + R_useDynamicSymbols(NULL, FALSE); +} + #endif From 937a6c04753a63b1804a16cf06c317a82e0497d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Cs=C3=A1rdi?= Date: Thu, 23 Apr 2026 14:44:45 +0200 Subject: [PATCH 21/21] Gracefull kill test improments Use px for a process w/ SIGTERM handler, instead of the extra shared library. --- R/utils.R | 13 --------- src/Makevars | 5 +--- src/test/sigtermignore.c | 18 ------------ src/tools/px.c | 54 +++++++++++++++++++++++++++++++++++ tests/testthat/helper.R | 4 --- tests/testthat/test-process.R | 21 +++++++------- 6 files changed, 66 insertions(+), 49 deletions(-) delete mode 100644 src/test/sigtermignore.c diff --git a/R/utils.R b/R/utils.R index 299fdc86..a7b8eb8d 100644 --- a/R/utils.R +++ b/R/utils.R @@ -363,16 +363,3 @@ rimraf <- function(...) { unlink(x, recursive = TRUE, force = TRUE) } -get_test_lib <- function(lib) { - if (pkgload::is_dev_package("processx")) { - path <- "src" - } else { - path <- paste0('libs', .Platform$r_arch) - } - - system.file( - package = "processx", - path, - paste0(lib, .Platform$dynlib.ext) - ) -} diff --git a/src/Makevars b/src/Makevars index 3d1a031b..c634710d 100644 --- a/src/Makevars +++ b/src/Makevars @@ -8,7 +8,7 @@ OBJECTS = init.o poll.o errors.o processx-connection.o \ all: tools/px tools/sock supervisor/supervisor client$(SHLIB_EXT) $(SHLIB) strip -strip: $(SHLIB) tools/px tools/sock supervisor/supervisor client$(SHLIB_EXT) sigtermignore$(SHLIB_EXT) +strip: $(SHLIB) tools/px tools/sock supervisor/supervisor client$(SHLIB_EXT) @if which strip >/dev/null && which uname >/dev/null && test "`uname`" = "Linux" && test "$$_R_SHLIB_STRIP_" = "true" && test -n "$$R_STRIP_SHARED_LIB"; then \ echo stripping $(SHLIB) tools/px tools/sock supervisor/supervisor client$(SHLIB_EXT); \ echo $$R_STRIP_SHARED_LIB $(SHLIB) tools/px tools/sock supervisor/supervisor client$(SHLIB_EXT); \ @@ -37,9 +37,6 @@ client$(SHLIB_EXT): $(CLIENT_OBJECTS) patchelf --remove-needed libR.so client$(SHLIB_EXT); \ fi -sigtermignore$(SHLIB_EXT): test/sigtermignore.o - $(SHLIB_LINK) -o sigtermignore$(SHLIB_EXT) test/sigtermignore.o - clean: rm -rf $(SHLIB) $(OBJECTS) $(CLIENT_OBJECTS) \ supervisor/supervisor supervisor/supervisor.dSYM \ diff --git a/src/test/sigtermignore.c b/src/test/sigtermignore.c deleted file mode 100644 index 168006eb..00000000 --- a/src/test/sigtermignore.c +++ /dev/null @@ -1,18 +0,0 @@ -#ifndef _WIN32 - -#include -#include -#include - -void R_init_sigtermignore(DllInfo *dll) { - signal(SIGTERM, SIG_IGN); -} - -// work around R CMD check false positive - -void dummy() { - R_registerRoutines(NULL, NULL, NULL, NULL, NULL); - R_useDynamicSymbols(NULL, FALSE); -} - -#endif diff --git a/src/tools/px.c b/src/tools/px.c index 46f71365..430b292c 100644 --- a/src/tools/px.c +++ b/src/tools/px.c @@ -50,6 +50,10 @@ void usage(void) { "write raw bytes (hex pairs) to stdout\n"); fprintf(stderr, " rawerr -- " "write raw bytes (hex pairs) to stderr\n"); + fprintf(stderr, " sigterm ignore -- " + "ignore SIGTERM\n"); + fprintf(stderr, " sigterm sleep -- " + "sleep a number of seconds on SIGTERM, then quit\n"); } void cat2(int f, const char *s) { @@ -112,6 +116,35 @@ int echo_from_fd(int fd1, int fd2, int nbytes) { return 0; } +#ifdef WIN32 +void sigterm_ignore(void) { } +void sigterm_sleep(double fnum) { } +#else +#include + +double sig_sleep_secs; + +void sigterm_ignore(void) { + signal(SIGTERM, SIG_IGN); +} + +void sig_handler_sleep(int sig, siginfo_t *info, void *ucontext) { + double fnum = sig_sleep_secs; + int num = (int) fnum; + sleep(num); + fnum = fnum - num; + if (fnum > 0) usleep((useconds_t)(fnum * 1000.0 * 1000.0)); +} + +void sigterm_sleep(double fnum) { + sig_sleep_secs = fnum; + struct sigaction sig = {{ 0 }}; + sig.sa_flags = SA_SIGINFO; + sig.sa_sigaction = &sig_handler_sleep; + sigaction(SIGTERM, &sig, NULL); +} +#endif + int main(int argc, const char **argv) { int num, idx, ret, fd, fd2, nbytes; @@ -258,6 +291,27 @@ int main(int argc, const char **argv) { } } + } else if (!strcmp("sigterm", cmd)) { + idx++; + if (!strcmp("ignore", argv[idx])) { + sigterm_ignore(); + } else if (!strcmp("sleep", argv[idx])) { + if (idx + 1 >= argc) { + fprintf(stderr, "Missing argument for 'sigterm sleep'\n"); + return 17; + } + idx++; + ret = sscanf(argv[idx], "%lf", &fnum); + if (ret != 1) { + fprintf(stderr, "Invalid seconds for px sigterm sleep: '%s'\n", argv[idx]); + return 18; + } + sigterm_sleep(fnum); + } else { + fprintf(stderr, "Invalid 'sigterm' subcommand\n"); + return 19; + } + } else { fprintf(stderr, "Unknown px command: '%s'\n", cmd); return 2; diff --git a/tests/testthat/helper.R b/tests/testthat/helper.R index e06f03bf..6d7db617 100644 --- a/tests/testthat/helper.R +++ b/tests/testthat/helper.R @@ -250,7 +250,3 @@ retry_until <- function(fn, interrupt = 0.2, timeout = 5) { stop("timeout") } -load_sigtermignore <- function() { - lib <- asNamespace("processx")$get_test_lib("sigtermignore") - dyn.load(lib) -} diff --git a/tests/testthat/test-process.R b/tests/testthat/test-process.R index 4fd95346..8bf7ab87 100644 --- a/tests/testthat/test-process.R +++ b/tests/testthat/test-process.R @@ -416,26 +416,27 @@ test_that("can use custom `cleanup_signal`", { }) }) -test_that("can load sigtermignore", { +test_that("process survives SIGTERM when ignored", { skip_on_os("windows") - p <- callr::r_session$new() + px <- get_tool("px") + p <- process$new(px, c("sigterm", "ignore", "outln", "ready", "sleep", "10"), + stdout = "|") defer(p$kill()) - - p$run(load_sigtermignore) - + p$poll_io(1000) + expect_equal(p$read_output_lines(), "ready") tools::pskill(p$get_pid(), tools::SIGTERM) tools::pskill(p$get_pid(), tools::SIGTERM) - expect_true(p$is_alive()) }) test_that("can kill with SIGTERM when ignored", { skip_on_os("windows") - p <- callr::r_session$new() + px <- get_tool("px") + p <- process$new(px, c("sigterm", "ignore", "outln", "ready", "sleep", "10"), + stdout = "|") defer(p$kill()) - - p$run(load_sigtermignore) - + p$poll_io(1000) + p$read_output_lines() p$signal(tools::SIGTERM) Sys.sleep(0.05) expect_true(p$is_alive())