Skip to content

Commit 3fc2f0c

Browse files
authored
Prevent parallel functions from forking Ark (#1308)
Addresses posit-dev/positron#3817 This shims `mcfork()`, the forking primitive used by all entry points in the parallel package. The shim fails with an informative R error. In addition, `makeForkCluster()` is also shimmed because it swallows `mcfork()` errors with an uninformative message. If `mc.cores` is set to 1, `mcfork()` is not called as the {parallel} function falls back to serial computation, which is the same behaviour as on Windows where forking is not possible.
1 parent 670a494 commit 3fc2f0c

5 files changed

Lines changed: 200 additions & 0 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#
2+
# fork_guard.R
3+
#
4+
# Copyright (C) 2026 Posit Software, PBC. All rights reserved.
5+
#
6+
#
7+
8+
# ark's IOPub/ZMQ background threads don't survive `fork()`
9+
# (posit-dev/positron#3817). Every forking path in {parallel} funnels through
10+
# the internal `mcfork()`, the only R-level caller of the C fork primitive. So
11+
# shimming `mcfork()` with a stub that errors immediately blocks them all,
12+
# pointing the user at backends that launch fresh subprocesses instead.
13+
#
14+
# We also shim `makeForkCluster()` directly. It wraps `mcfork()` in a
15+
# `tryCatch()` and rethrows a generic "Cluster setup failed." on any error, so
16+
# our message would never reach the user otherwise. `makeCluster(type = "FORK")`
17+
# dispatches to `makeForkCluster()` through the namespace, so it is covered too.
18+
#
19+
# `mclapply()` and friends fall back to a serial `lapply()`/`mapply()` when
20+
# `mc.cores = 1` or the input has fewer than two elements. Those paths never
21+
# reach `mcfork()`, so they keep working and run serially, matching Windows
22+
# where {parallel} ships serial stubs (`R/windows/mcdummies.R`) and never forks.
23+
24+
initialize_fork_guard <- function() {
25+
# These functions only fork on Unix. On Windows {parallel} never forks, so
26+
# there is nothing to guard.
27+
if (is_windows()) {
28+
return(invisible())
29+
}
30+
31+
if (isNamespaceLoaded("parallel")) {
32+
bind_fork_guard_ns()
33+
if ("package:parallel" %in% search()) {
34+
bind_fork_guard_pkg()
35+
}
36+
}
37+
38+
setHook(packageEvent("parallel", "onLoad"), function(...) {
39+
bind_fork_guard_ns()
40+
})
41+
setHook(packageEvent("parallel", "attach"), function(...) {
42+
bind_fork_guard_pkg()
43+
})
44+
}
45+
46+
# `mcfork()` is internal, so the namespace binding covers every caller. Only
47+
# `makeForkCluster()` is exported, so it also needs a package binding for
48+
# unqualified calls after `library(parallel)`.
49+
fork_guard_ns_names <- c("mcfork", "makeForkCluster")
50+
fork_guard_pkg_names <- "makeForkCluster"
51+
52+
bind_fork_guard_ns <- function() {
53+
for (name in fork_guard_ns_names) {
54+
ns_bind("parallel", name, make_fork_guard_shim(name))
55+
}
56+
}
57+
58+
bind_fork_guard_pkg <- function() {
59+
for (name in fork_guard_pkg_names) {
60+
pkg_bind("parallel", name, make_fork_guard_shim(name))
61+
}
62+
}
63+
64+
make_fork_guard_shim <- function(name) {
65+
original <- utils::getFromNamespace(name, "parallel")
66+
67+
# `ns_bind()`/`pkg_bind()` require the replacement to share the original's
68+
# formals.
69+
shim <- function() {}
70+
formals(shim) <- formals(original)
71+
body(shim) <- quote(stop_no_fork())
72+
73+
shim
74+
}
75+
76+
stop_no_fork <- function() {
77+
msg <- paste_line(c(
78+
sprintf("Can't fork the R session in %s.", app_name()),
79+
"Use a backend that starts fresh R processes instead: PSOCK clusters,",
80+
"`future::multisession()`, mirai, or `purrr::in_parallel()`."
81+
))
82+
stop(msg, call. = FALSE)
83+
}

crates/ark/src/modules/positron/init.R

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,4 +158,5 @@ initialize <- function() {
158158
initialize_debug()
159159
initialize_hooks()
160160
initialize_hooks_source()
161+
initialize_fork_guard()
161162
}

crates/ark/src/modules/positron/system.R

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ is_windows <- function() {
1818
system_os() == "windows"
1919
}
2020

21+
#' Name of the frontend Ark is running under.
22+
#'
23+
#' Positron sets the `POSITRON` env var when it launches Ark. Same signal
24+
#' `positron.R` uses to set `.Platform$GUI`.
25+
app_name <- function() {
26+
if (Sys.getenv("POSITRON") == 1) {
27+
"Positron"
28+
} else {
29+
"Ark"
30+
}
31+
}
32+
2133
has_aqua <- function() {
2234
capabilities("aqua")
2335
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
use ark_test::DummyArkFrontend;
2+
3+
// `parallel` ships with base R, so no extra test dependency is needed.
4+
//
5+
// The fork guard only installs on Unix, because forking is the hazard and
6+
// Windows `parallel` never forks (it ships its own serial/`stop()` stubs in
7+
// `R/windows/mcdummies.R`). So the error tests below are `#[cfg(unix)]`: on
8+
// Windows these calls hit R's native behavior, which uses different messages
9+
// and even runs serially in some cases. The PSOCK tests stay cross-platform.
10+
11+
#[cfg(unix)]
12+
#[test]
13+
fn test_fork_functions_error() {
14+
let frontend = DummyArkFrontend::lock();
15+
16+
frontend.execute_request_error(
17+
"parallel::mclapply(1:2, identity, mc.cores = 2)",
18+
|error_msg| {
19+
assert!(error_msg.contains("fork the R session"));
20+
},
21+
);
22+
23+
// `library(parallel)` attaches the package, exercising the `pkg_bind` /
24+
// attach path. Unqualified `mclapply` should then hit the shim too.
25+
frontend.execute_request_error("library(parallel); mclapply(1:2, identity)", |error_msg| {
26+
assert!(error_msg.contains("fork the R session"));
27+
});
28+
29+
frontend.execute_request_error("parallel::mcparallel(1)", |error_msg| {
30+
assert!(error_msg.contains("fork the R session"));
31+
});
32+
33+
frontend.execute_request_error("parallel::pvec(1:10, sqrt, mc.cores = 2)", |error_msg| {
34+
assert!(error_msg.contains("fork the R session"));
35+
});
36+
37+
frontend.execute_request_error(
38+
"parallel::mcmapply(`+`, 1:2, 3:4, mc.cores = 2)",
39+
|error_msg| {
40+
assert!(error_msg.contains("fork the R session"));
41+
},
42+
);
43+
44+
frontend.execute_request_error("parallel::mcMap(`+`, 1:2, 3:4)", |error_msg| {
45+
assert!(error_msg.contains("fork the R session"));
46+
});
47+
48+
frontend.execute_request_error("parallel::makeForkCluster(2)", |error_msg| {
49+
assert!(error_msg.contains("fork the R session"));
50+
});
51+
52+
// `mcfork` is unexported, so triple-colon access exercises the
53+
// namespace-only binding.
54+
frontend.execute_request_error("parallel:::mcfork()", |error_msg| {
55+
assert!(error_msg.contains("fork the R session"));
56+
});
57+
58+
// The public `makeCluster(type = "FORK")` dispatches to `makeForkCluster`
59+
// resolved through parallel's namespace, so it hits our shim too.
60+
frontend.execute_request_error("parallel::makeCluster(2, type = \"FORK\")", |error_msg| {
61+
assert!(error_msg.contains("fork the R session"));
62+
});
63+
}
64+
65+
// The multicore family runs serially, without forking, when `mc.cores = 1` or
66+
// the input has fewer than two elements. Those calls never reach `mcfork()`, so
67+
// the guard must leave them alone. This matches Windows, where {parallel} always
68+
// runs these serially.
69+
70+
#[test]
71+
fn test_serial_execution_works() {
72+
let frontend = DummyArkFrontend::lock();
73+
74+
frontend.execute_request(
75+
"unlist(parallel::mclapply(1:4, function(x) x * 2L, mc.cores = 1))",
76+
|result| assert_eq!(result, "[1] 2 4 6 8"),
77+
);
78+
79+
frontend.execute_request(
80+
"parallel::pvec(1:4, function(x) x * 2L, mc.cores = 1)",
81+
|result| assert_eq!(result, "[1] 2 4 6 8"),
82+
);
83+
}
84+
85+
// PSOCK clusters start fresh R worker processes instead of forking the session,
86+
// so the guard must leave them untouched. These spawn real worker processes and
87+
// run a small parallel computation, asserting it returns the correct result.
88+
89+
#[test]
90+
fn test_psock_cluster_works() {
91+
let frontend = DummyArkFrontend::lock();
92+
93+
frontend.execute_request(
94+
"local({ cl <- parallel::makePSOCKcluster(2); on.exit(parallel::stopCluster(cl)); sum(unlist(parallel::parLapply(cl, 1:4, function(x) x * 2L))) })",
95+
|result| assert_eq!(result, "[1] 20"),
96+
);
97+
98+
// `makeCluster()` defaults to a PSOCK cluster, which we don't guard.
99+
frontend.execute_request(
100+
"local({ cl <- parallel::makeCluster(2); on.exit(parallel::stopCluster(cl)); unlist(parallel::clusterApply(cl, 1:3, function(x) x + 1L)) })",
101+
|result| assert_eq!(result, "[1] 2 3 4"),
102+
);
103+
}

crates/ark/tests/integration/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ mod kernel_captured_output;
2626
mod kernel_debugger;
2727
mod kernel_execute;
2828
mod kernel_execute_error;
29+
mod kernel_fork_guard;
2930
mod kernel_hooks_session;
3031
mod kernel_hooks_source;
3132
mod kernel_hooks_timestamp;

0 commit comments

Comments
 (0)