Skip to content

Warning: Graphics device changes when using ggplotGrob inside future::future_lapply with multisession plan #6464

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
elgabbas opened this issue May 20, 2025 · 7 comments

Comments

@elgabbas
Copy link

elgabbas commented May 20, 2025

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: futureverse/future#788

@clauswilke
Copy link
Member

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.

@elgabbas
Copy link
Author

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.

@teunbrand
Copy link
Collaborator

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().

@elgabbas
Copy link
Author

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]])

@clauswilke
Copy link
Member

@teunbrand Yes, this is exactly what happens. If you request font metrics and no graphics device is open then R just opens one.

@teunbrand
Copy link
Collaborator

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.

@thomasp85
Copy link
Member

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants