Skip to content

Non-blocking mode for runApp()#4349

Open
shikokuchuo wants to merge 30 commits intomainfrom
non-blocking
Open

Non-blocking mode for runApp()#4349
shikokuchuo wants to merge 30 commits intomainfrom
non-blocking

Conversation

@shikokuchuo
Copy link
Member

@shikokuchuo shikokuchuo commented Feb 4, 2026

Motivation

AI coding agents (such as Claude Code) need to programmatically start, test, and stop Shiny apps without blocking the R console. Currently, runApp() blocks until the app is stopped, making it cumbersome for agents to:

  • Start an app, interact with it via HTTP requests, and then stop it
  • Run automated tests against a live app
  • Manage multiple sequential app launches in a single session

Changes

Adds a blocking parameter to runApp(), runExample(), and runGadget():

# Returns immediately with a handle
handle <- runApp(app, blocking = FALSE)                                            

# Check status and URL                       
handle$status() # "running", "success" or "error"
handle$url()

# Stop when done
handle$stop()

# Access return value from `stopApp()`
handle$result() # throws if still running, re-throws errors

Implementation

  • ShinyAppHandle (R6 class): Returned when blocking = FALSE. Provides methods for lifecycle management and accessing the app's return value.
  • serviceAsync(): Runs the httpuv event loop via later callbacks (1ms delay) instead of a blocking while loop.
  • createCleanup(): Consolidated cleanup logic shared between blocking and non-blocking modes.
  • Default can be changed globally via options(shiny.blocking = FALSE).

Safeguards

  • Only one app can run at a time (same as blocking mode)
  • Finalizer ensures cleanup if handle is garbage collected
  • Cleanup is idempotent (safe to call multiple times)

@shikokuchuo shikokuchuo marked this pull request as ready for review February 5, 2026 15:44
Copy link
Member Author

@shikokuchuo shikokuchuo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From a conversations with @hadley, we could tweak the UI further so that:

  1. Starting a new app automatically stops the old one
  2. Have it automatically run non-blocking if used by an agent (via env vars)

@hadley
Copy link
Member

hadley commented Feb 13, 2026

I like the model of automatically stopping the previously running app as it matches the existing model pretty closely; it just skips the step of having to Ctrl + C to quit the current app.

I would hope that eventually nonblocking mode becomes the default, but defaulting to only during LLM usage would be a good place to start.

@shikokuchuo
Copy link
Member Author

Thanks @hadley, starting a new app now automatically stops the old one, and non-blocking is the default for LLMs.
Am reviewing with @cpsievert and @schloerke later this week.

Comment on lines +54 to +59
#' @return If `blocking = TRUE`, returns the value passed to [stopApp()], or
#' throws an error if the app was stopped with an error. If `blocking = FALSE`,
#' returns a `ShinyAppHandle` object with methods `stop()`, `status()`,
#' `url()`, and `result()`. The `status()` method returns `"running"`,
#' `"success"`, or `"error"`. The `result()` method throws an error if called
#' while running, or re-throws the error if the app stopped with an error.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am worried about the goals of the future desired behavior.

I believe we need a different function for non-blocking runApp. Returning vastly different objects given a new parameter isn't right.

  1. Non-blocking for LLMs only
    • On CRAN, LLMS already know to run in the background. If this was switched to be non-blocking, the app won't run as expected. To mitigate this, we'd need to tell the LLM to read the docs... which it could read about the new method instead.
  2. If the future goal is to make non-blocking the default behavior, many programs (Posit Connect included) use runApp() as is. To add a parameter to make it non-blocking will cause pain for all existing Connect users who do not have the bleeding edge version. This will take years to recover from as updating Connect versions is a non-trivial process for admins.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the goal is to expose state to AI, maybe startApp() as it follows {httpuv}'s startServer() ?

It would need doc updates in stopApp() in that they are unrelated.

@schloerke
Copy link
Collaborator

I would hope that eventually nonblocking mode becomes the default, but defaulting to only during LLM usage would be a good place to start.

This would be very painful for pro products (Connect admins) that use shiny::runApp(). I strongly disagree with the future goal of having non-blocking be the default for runApp().

For LLM usage to be non-blocking, my interactions with running a shiny app is that the app is launched in a background process that the LLM watches. If the Shiny process then suddenly became non-blocking (because it is being run in an LLM), the R process would quit after the initial tick. User's wouldn't be able to replicate the behavior running it interactively. (... Step 4. Profit Chaos ).

The pro products would need conditional behavior based on the package version. So we might as well use another method due to properly support the drastically different behavior and return type. Proposal: shiny::startApp().

@cpsievert
Copy link
Collaborator

cpsievert commented Feb 18, 2026

This would be very painful for pro products (Connect admins) that use shiny::runApp()

More painful than having the hosting environments set options(shiny.blocking = TRUE)?

Proposal: shiny::startApp().

What about running an app by printing a shinyApp() object?

@shikokuchuo
Copy link
Member Author

What about running an app by printing a shinyApp() object?

I thought of this as well. It's convenient to be able to modify the behaviour of the implicit runApp() from the print method.

Also in this case, people call it primarily for the side effect of running the App. What runApp() returns then becomes secondary for me and I'm not so bothered about stability of return types.

@shikokuchuo
Copy link
Member Author

@cpsievert just to update that I tested the Rstudio Run App button with options(shiny.blocking = FALSE), and the different options run in the same way as when blocking - nothing surprising in that regard.

@shikokuchuo
Copy link
Member Author

shikokuchuo commented Mar 13, 2026

Let's move this forward on the following basis (as discussed in today's team meeting):

  • Add blocking = getOption("shiny.blocking", TRUE) to runApp()
  • Make this NON-default for LLMs or otherwise

This has the following advantages:

  • Strictly opt-in - no surprising behaviour
  • We can still teach an LLM how to use non-blocking mode correctly via docs or a skill
  • All current infra (incl. RStudio button) works in BOTH modes
  • Single change location (no need to modify shinyApp print method etc.)

Noting that @schloerke continues to prefer 2 separate functions.

@cpsievert for you to approve this PR. Thanks!

@shikokuchuo shikokuchuo requested review from cpsievert and removed request for jcheng5 March 17, 2026 11:33
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

Successfully merging this pull request may close these issues.

4 participants