Skip to content

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

Merged
cpsievert merged 51 commits into
mainfrom
non-blocking
May 14, 2026
Merged

Add startApp() for non-blocking app execution#4349
cpsievert merged 51 commits into
mainfrom
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 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.
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 targeted follow-ups from local verification. The main correctness issue is the startup-time stop path in startApp(). I also think the fixed 1 ms polling loop is worth revisiting before this lands. Details are inline.

Comment thread R/runapp.R Outdated
Comment thread R/server.R Outdated
Comment thread R/runapp.R Outdated
@shikokuchuo shikokuchuo requested a review from cpsievert May 8, 2026 11:09
Comment thread R/runapp.R Outdated
Comment thread R/runapp.R
Comment thread R/runapp.R
Comment thread R/server.R
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.

Great work, thanks @shikokuchuo!

Comment thread NEWS.md Outdated
@cpsievert cpsievert merged commit 929143f into main May 14, 2026
12 checks passed
@cpsievert cpsievert deleted the non-blocking branch May 14, 2026 15:48
@github-actions github-actions Bot restored the non-blocking branch May 14, 2026 15:51
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