Skip to content

Add startApp() for non-blocking app execution#4349

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

Add startApp() for non-blocking app execution#4349
shikokuchuo wants to merge 40 commits intomainfrom
non-blocking

Conversation

@shikokuchuo
Copy link
Copy Markdown
Member

@shikokuchuo shikokuchuo commented Feb 4, 2026

Motivation

Shiny, being an httpuv server, inherently does not need to block — it uses the later event loop, which is designed for cooperative concurrency. Adding a non-blocking mode:

  • Allows AI coding agents to programmatically start, test, and stop Shiny apps without blocking the R console and run multiple apps sequentially in the same session
  • Opens up novel uses of Shiny as a UI for objects that live on in the user's interactive R session

Changes

Adds startApp(), a non-blocking counterpart to runApp():

# Start app in the background — returns immediately
handle <- startApp("myapp")

# 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 by startApp(). Provides methods for lifecycle management (stop(), status(), url()) and accessing the app's return value (result()).
  • .setupShinyApp(): Shared initialization extracted from runApp(), used by both runApp() and startApp().
  • serviceNonBlocking(): Runs the httpuv event loop via later callbacks instead of a blocking while loop.
  • .createCleanup(): Consolidated cleanup logic shared between blocking and non-blocking modes.

Safeguards

  • Only one app can run at a time (same as blocking mode)
  • When a new app starts, any previously running non-blocking app is automatically stopped
  • 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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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.

@shikokuchuo
Copy link
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Comment thread R/app-handle.R Outdated
Comment thread R/runapp.R Outdated
Comment thread R/runapp.R Outdated
@shikokuchuo
Copy link
Copy Markdown
Member Author

Following discussion at Asilomar, non-blocking behaviour is now separated out into a startApp() function so it doesn't interfere at all with existing runApp() behaviour.

@cpsievert
Copy link
Copy Markdown
Collaborator

@shikokuchuo Could you please update the PR description?

@shikokuchuo shikokuchuo changed the title Non-blocking mode for runApp() Add startApp() for non-blocking app execution Apr 10, 2026
@shikokuchuo
Copy link
Copy Markdown
Member Author

@shikokuchuo Could you please update the PR description?

Ah, of course - all done now!

Copy link
Copy Markdown
Collaborator

@cpsievert cpsievert left a comment

Choose a reason for hiding this comment

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

A few issues spotted during review — details in inline comments.

Comment thread R/runapp.R Outdated
Comment thread R/runapp.R Outdated
Comment thread R/runapp.R Outdated
Comment thread tests/testthat/test-non-blocking.R Outdated
Aligns `startApp()` with `runApp()` by setting `options(warn,
pool.scheduler)` before `as.shiny.appobj()` and passing `ops` through.
Folds the `findVal` precedence block into `.setupShinyApp()`; missingness
is checked in the caller's frame via a `caller = parent.frame()` default
arg, since `runApp()`/`startApp()` formals carry defaults.
`local_otel_promise_domain()` binds the domain to the caller's frame,
which in `startApp()` exits before any request is served. A persistent
global install would leak into unrelated user promises between ticks.

Wrap the synchronous setup phase and each service iteration in
`with_otel_promise_domain()`. Callbacks are wrapped at registration
time, so promises created during `onStart`, handlers, and observers
stay instrumented when they fire. The domain is dormant between ticks,
so it stays out of user promises at the console.
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