Warning: Graphics device changes when using `ggplotGrob` inside `future::future_lapply` with multisession plan
When using ggplot2::ggplotGrob() (and indirectly ggExtra::ggMarginal()) inside a future::future_lapply() call with plan(multisession), warnings appear related to unexpected graphics device changes. This breaks the expected isolation of child processes: child workers should not open or close graphics devices that persist outside their scope or cause side effects detectable by the parent.
Specifically, the warnings mention that a new device (usually pdf) is opened without being closed properly, or the device state changes unexpectedly during the future evaluation.
This happens even when dev.list() returns NULL before execution, and attempts to pre-open and explicitly close devices inside the future expression (e.g., using grDevices::pdf() and dev.off()) do not fully suppress these warnings.
This makes it difficult to safely use ggplot2 in parallel workflows using multisession futures, where device state consistency is critical.
Reprex:
library(ggplot2)
library(grid)
library(future.apply)
plan(multisession, workers = 2)
plot_function <- function(x) {
p <- ggplot(data.frame(x = rnorm(100), y = rnorm(100)), aes(x, y)) +
geom_point()
grob <- ggplotGrob(p)
return(grob)
}
results <- future_lapply(1:5, plot_function, future.seed = TRUE)
# Warning messages:
# 1: MultisessionFuture (‘future_lapply-1’) added, removed, or modified devices. A future expression must close
any opened devices and must not close devices it did not open. Details: 1 devices differ: index=2, before=‘NA’, after=‘pdf’
# 2: MultisessionFuture (‘future_lapply-2’) added, removed, or modified devices. A future expression must close
any opened devices and must not close devices it did not open. Details: 1 devices differ: index=2, before=‘NA’, after=‘pdf’
Opening a temporary pdf device explicitly before plotting and closing it afterwards did not fully solve the issue:
# new R session
library(ggplot2)
library(grid)
library(future.apply)
plan(multisession, workers = 2)
plot_function <- function(x) {
# Open a temporary graphics device to absorb side effects
tmp <- tempfile(fileext = ".pdf")
grDevices::pdf(tmp)
on.exit(grDevices::dev.off(), add = TRUE)
# Create plot and convert to grob
p <- ggplot(data.frame(x = rnorm(100), y = rnorm(100)), aes(x, y)) +
geom_point()
grob <- ggplotGrob(p)
return(grob)
}
results <- future_lapply(1:5, plot_function, future.seed = TRUE)
# Warning messages:
# 1: MultisessionFuture (‘future_lapply-1’) added, removed, or modified devices. A future expression must close
any opened devices and must not close devices it did not open. Details: 1 devices differ: index=2, before=‘NA’, after=‘’
# 2: MultisessionFuture (‘future_lapply-2’) added, removed, or modified devices. A future expression must close
any opened devices and must not close devices it did not open. Details: 1 devices differ: index=2, before=‘NA’, after=‘’
What internal calls in ggplot2::ggplotGrob() or related grid functions might open a graphics device implicitly, especially in headless or parallel contexts?
Is there a recommended way to preemptively open and close devices, or to prevent implicit device openings, when using ggplot2 grobs in parallel processes?
> sessionInfo()
R version 4.5.0 (2025-04-11 ucrt)
Platform: x86_64-w64-mingw32/x64
Running under: Windows 10 x64 (build 19045)
Matrix products: default
LAPACK version 3.12.1
locale:
[1] LC_COLLATE=English_United States.utf8 LC_CTYPE=English_United States.utf8
[3] LC_MONETARY=English_United States.utf8 LC_NUMERIC=C
[5] LC_TIME=English_United States.utf8
time zone: Europe/Berlin
tzcode source: internal
attached base packages:
[1] grid stats graphics grDevices utils datasets methods base
other attached packages:
[1] future.apply_1.11.3 future_1.49.0 ggplot2_3.5.2
loaded via a namespace (and not attached):
[1] vctrs_0.6.5 cli_3.6.5 rlang_1.1.6 generics_0.1.4
[5] glue_1.8.0 listenv_0.9.1 scales_1.4.0 tibble_3.2.1
[9] lifecycle_1.0.4 compiler_4.5.0 dplyr_1.1.4 codetools_0.2-20
[13] RColorBrewer_1.1-3 pkgconfig_2.0.3 rstudioapi_0.17.1 farver_2.1.2
[17] digest_0.6.37 R6_2.6.1 dichromat_2.0-0.1 tidyselect_1.2.1
[21] pillar_1.10.2 parallelly_1.44.0 parallel_4.5.0 magrittr_2.0.3
[25] tools_4.5.0 withr_3.0.2 gtable_0.3.6 globals_0.18.0
See also: https://github.com/futureverse/future/discussions/788
I have fought with these types of issues a lot in my cowplot package. The brief answer is converting a plot into a grob should ideally only be done when actually rendering the plot into an opened graphics device. Otherwise you will run into all sorts of issues. However, you could try the cowplot::as_grob() function to see if raises similar warnings.
Here is the relevant source code, which is quite cumbersome and lists some of the problems that can arise in different settings: https://github.com/wilkelab/cowplot/blob/34819eb709ed590d90a74ac7c39132e832bb94fc/R/as_grob.R#L82
The reason why a graphics device needs to be open is that we need font metrics to generate the grob and we don't have access to font metrics without having a graphics device open. An additional problem is that the font metrics will (subtly) change for different graphics devices and settings (for example, for raster devices, they can depend on the size of the output image), so we're almost guaranteed to have incorrect font metrics if we convert to a grob outside of final rendering. Again, this is where we go back to the point that ideally we wouldn't do this in the first place.
Thanks @clauswilke. I tried a couple of examples using cowplot::as_grob() and I get either warnings or errors
plot_function <- function(x) {
p <- ggplot(data.frame(x = rnorm(100), y = rnorm(100)), aes(x, y)) +
geom_point()
# warnings
grob <- cowplot::as_grob(p)
# grob <- cowplot::as_grob(p, device = pdf)
# grob <- cowplot::as_grob(p, device = png)
# Error in device(width = 6, height = 6) : could not find function "device"
# grob <- cowplot::as_grob(p, device = "pdf")
# grob <- cowplot::as_grob(p, device = "cairo")
# grob <- cowplot::as_grob(p, device = "png")
# grob <- cowplot::as_grob(p, device = "agg")
# Error in ...future.FUN(...future.X_jj, ...) : object 'cairo' not found
# grob <- cowplot::as_grob(p, device = cairo)
# Error in ...future.FUN(...future.X_jj, ...) : object 'agg' not found
# grob <- cowplot::as_grob(p, device = agg)
return(grob)
}
results <- future_lapply(1:5, plot_function, future.seed = TRUE)
As it is necessary to open a device to get font metrics, how device is opened in ggplot and is it explicitly closed afterwards? My understanding is that this opens device (which is probably fine) without closing it.
how device is opened in ggplot and is it explicitly closed afterwards?
AFAIK ggplot2 doesn't open graphics devices explicitly except in ggsave(). It might be that opening graphics devices is a side-effect from functions such as grid::convertWidth().
I think I found a temporary solution that disables the DeviceMisuseFutureWarning warnings.
library(ggplot2)
library(grid)
library(ggExtra)
library(future.apply)
plan(multisession, workers = 2)
plot_function1 <- function(x) {
warning("warning 1", call. = FALSE)
data <- data.frame(x = rnorm(100), y = rnorm(100))
Plot <- ggplot(data, aes(x, y)) + geom_point(color = "steelblue4")
warning("warning 2", call. = FALSE)
Plot_Marginal <- ggExtra::ggMarginal(
p = Plot, type = "histogram", margins = "y", size = 6,
color = "steelblue4", fill = "steelblue4", bins = 50
)
warning("warning 3", call. = FALSE)
Plot_Marginal
}
plot_function2 <- function(x) {
warning("warning 1", call. = FALSE)
p <- ggplot(data.frame(x = rnorm(100), y = rnorm(100)), aes(x, y)) +
geom_point()
warning("warning 2", call. = FALSE)
grob <- ggplotGrob(p)
warning("warning 3", call. = FALSE)
return(grob)
}
suppress_device_warning <- function(expr) {
withCallingHandlers(
expr,
warning = function(w) {
msg <- conditionMessage(w)
if (inherits(w, "DeviceMisuseFutureWarning") ||
grepl("added, removed, or modified devices", msg)) {
invokeRestart("muffleWarning")
}
}
)
}
# no warnings
suppress_device_warning({
outputs1 <- future_lapply(1:5, plot_function1, future.seed = TRUE)
})
plot(outputs1[[1]])
# no warnings
suppress_device_warning({
outputs2 <- future_lapply(1:5, plot_function2, future.seed = TRUE)
})
plot(outputs2[[1]])
@teunbrand Yes, this is exactly what happens. If you request font metrics and no graphics device is open then R just opens one.
Right, but that isn't really behaviour ggplot2 has any sensible control over so there isn't much to adapt in ggplot2's code, I think.
It is possible to avoid it in ggplot2 at the cost of performance.
Text rendering is clearly moving towards an A and B tier device support, so it is likely that e.g. the string dimensions we get from one device cannot sensibly be used with another device.
It might make sense to investigate wether the performance benefit that prompted the upfront-unit conversion still exist today
It is possible to avoid it in ggplot2 at the cost of performance.
Is this: just check in ggplot_gtable() whether a device is open, and if not, open and close a temporary one?
Because if we only have to do this once per plot, it wouldn't be so terrible, I imagine.
@teunbrand No, temporary graphics devices cause a bunch of issues and are best avoided. If only because you don't typically get the correct font metrics as the temporary device will in general not exactly match the final device used for rendering. I'm not sure what @thomasp85 is thinking of but in general it is best to defer all requests for font metrics etc. until the final rendering time, so the correct graphics device is available and open. The problem is there are many extension packages out there that violate this rule. My own are among the worst offenders. I'm not sure off the top of my head whether ggplot2 itself has this problem anywhere in its code base. I think not.
In ggplot2 we convert grob dimensions to absolute units in eg titleGrob. We do this because it is more performant to calculate up front but we could choose not to do it and keep it in grobWidth and grobHeight dimensions
it is best to defer all requests for font metrics etc. until the final rendering time
Yes I agree, but if nobody has opened a GD, then grid will open one for us (and never close it). So the problems with font metrics are already happening, they just aren't extremely noticible.
I should have checked this before I started puzzling, but I don't think this is a ggplot2 issue.
Even if you very reasonably open and close a graphics device, future_lapply() will complain.
library(future.apply)
#> Loading required package: future
plan(multisession, workers = 2)
plot_function <- function(x) {
dev.new()
cur <- dev.cur()
dev.off(cur)
return(1)
}
results <- future_lapply(1:5, plot_function, future.seed = TRUE)
#> Warning: MultisessionFuture ('future_lapply-1') opened the default graphics device (1: c("do.call(function(...) {", " \"# future::getGlobalsAndPackages(): FUN() uses '...' internally \"", " \"# without having an '...' argument. This means '...' is treated\"", " \"# as a global variable. This may happen when FUN() is an \"", " \"# anonymous function. \"", " \"# \"", " \"# If an anonymous function, we will make sure to restore the \"", " \"# function environment of FUN() to the calling environment. \"",
#> " \"# We assume FUN() an anonymous function if it lives in the \"", " \"# global environment, which is where globals are written. \"", " penv <- env <- environment(...future.FUN)", " repeat {", " if (identical(env, globalenv()) || identical(env, emptyenv())) ", " break", " penv <- env", " env <- parent.env(env)", " }", " if (identical(penv, globalenv())) {", " environment(...future.FUN) <- environment()", " }", " else if (!identical(penv, emptyenv()) && !is.null(penv) && ",
#> " !isNamespace(penv)) {", " parent.env(penv) <- environment()", " }", " rm(list = c(\"env\", \"penv\"), inherits =
# [truncated for brevity purposes]
Created on 2025-09-29 with reprex v2.1.1
In this case, it doesn't matter if ggplot2 is well-behaved with regards to graphics devices: you'll get these warnings anyway. Unless there is another compelling reason for ggplot2 to close graphics devices that grid opens, I propose to close this issue.
Closing this issue for reasons mentioned above. We can re-open if the above code works as expected.