Compare commits

...

40 Commits

Author SHA1 Message Date
shikokuchuo
c4c91f62a1 Scope OTEL promise domain to startApp() setup and service loop
`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.
2026-04-17 15:08:54 +01:00
shikokuchuo
88b4facb8f Move option resolution into .setupShinyApp()
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.
2026-04-17 12:48:42 +01:00
shikokuchuo
957b50d3b6 Fix stale variable name in test comment 2026-04-17 12:27:52 +01:00
shikokuchuo
da50bf2249 Add startApp() to pkgdown.yml 2026-04-15 21:18:39 +01:00
shikokuchuo
72636ef4a0 Amend stopApp() docs 2026-04-10 15:28:07 +01:00
shikokuchuo
02a5e0b40f Separate out non-blocking into startApp() 2026-04-10 15:11:08 +01:00
Charlie Gao
0ff93d411f Apply suggestions from code review
Co-authored-by: Carson Sievert <cpsievert1@gmail.com>
2026-03-20 10:31:07 +00:00
shikokuchuo
bd250962e4 Amend currently running message 2026-03-20 10:29:37 +00:00
shikokuchuo
3a0e8627a4 Have handle$stop() method handle setting of stopped flag 2026-03-20 10:26:21 +00:00
shikokuchuo
b813adec56 Move generation increment before the blocking/non-blocking branch 2026-03-18 10:41:56 +00:00
shikokuchuo
f29fa65af9 Use generation invalidation for loops 2026-03-17 22:49:37 +00:00
shikokuchuo
36e7a330d6 Update news 2026-03-17 22:32:51 +00:00
shikokuchuo
6d984266f9 devtools::document() (GitHub Actions) 2026-03-13 18:54:05 +00:00
shikokuchuo
c27c186c0f Make non-default for LLMs 2026-03-13 18:51:09 +00:00
Charlie Gao
2907e83c42 Merge branch 'main' into non-blocking 2026-03-05 14:09:04 +00:00
shikokuchuo
45985690b2 Apply review changes from @cpsievert and @schloerke 2026-02-18 18:39:07 +00:00
shikokuchuo
ce11abe46d Merge branch 'main' into non-blocking 2026-02-18 12:19:35 +00:00
shikokuchuo
7e8903f754 Consistent cleanup order 2026-02-18 12:05:10 +00:00
shikokuchuo
664cbe2858 Move cleanup func creation 2026-02-18 12:00:06 +00:00
shikokuchuo
63af3649c8 Minimize changes to on.exit() handlers 2026-02-18 11:46:34 +00:00
shikokuchuo
3cb928e894 Simplify serviceAsync() 2026-02-17 12:40:47 +00:00
shikokuchuo
8e63d08d8a Make handle$stop() idempotent 2026-02-17 12:21:22 +00:00
Charlie Gao
1db26f60af Merge branch 'main' into non-blocking 2026-02-17 12:09:31 +00:00
shikokuchuo
1432920a7e Refactor pass 2026-02-17 11:50:11 +00:00
shikokuchuo
3882d1e4c3 Update news and docs 2026-02-17 11:08:52 +00:00
shikokuchuo
532c17081a Auto-stop upon new runApp() 2026-02-17 10:54:04 +00:00
shikokuchuo
329bc979c6 Default to non-blocking for LLMs 2026-02-17 10:39:14 +00:00
shikokuchuo
0456847883 Add documentation about running the later loop 2026-02-06 21:47:50 +00:00
shikokuchuo
6bbe29a390 Fix cleanup step and add regression test 2026-02-06 21:24:45 +00:00
shikokuchuo
c8bfa93747 Consolidate two on.exit() clauses 2026-02-06 20:31:45 +00:00
Charlie Gao
27134d9c66 Merge branch 'main' into non-blocking 2026-02-06 19:20:15 +00:00
shikokuchuo
48540283a4 Use combined error message 2026-02-05 15:34:43 +00:00
shikokuchuo
49b76badcc Rename internal function 2026-02-05 15:07:40 +00:00
shikokuchuo
935de77aee Cleanup pass 2026-02-05 14:42:16 +00:00
shikokuchuo
8b53c6d2fd Silence tests 2026-02-05 14:03:59 +00:00
shikokuchuo
3ccbad7a70 Tighten later loop for performance 2026-02-05 13:53:38 +00:00
shikokuchuo
13812b45a7 Review internal methods 2026-02-05 12:45:03 +00:00
shikokuchuo
08680d9566 Review user interface 2026-02-05 11:40:52 +00:00
shikokuchuo
620e5a277b Update unrelated tabPanel test snapshot 2026-02-04 19:41:06 +00:00
shikokuchuo
bb26c0f4d3 Non-blocking concept 2026-02-04 16:28:06 +00:00
11 changed files with 739 additions and 83 deletions

View File

@@ -126,6 +126,7 @@ Encoding: UTF-8
Roxygen: list(markdown = TRUE)
RoxygenNote: 7.3.3
Collate:
'app-handle.R'
'globals.R'
'app-state.R'
'app_template.R'

View File

@@ -276,6 +276,7 @@ export(snapshotPreprocessInput)
export(snapshotPreprocessOutput)
export(span)
export(splitLayout)
export(startApp)
export(stopApp)
export(strong)
export(submitButton)

View File

@@ -1,5 +1,12 @@
# shiny (development version)
## New features
* New `startApp()` runs a Shiny app in non-blocking mode, returning a
`ShinyAppHandle` object with `stop()`, `status()`, `url()`, and `result()`
methods. When a new app is started, any previously running non-blocking app
is automatically stopped.
# shiny 1.13.0
## New features

73
R/app-handle.R Normal file
View File

@@ -0,0 +1,73 @@
# Handle returned by startApp()
ShinyAppHandle <- R6::R6Class("ShinyAppHandle",
cloneable = FALSE,
public = list(
initialize = function(appUrl, cleanupFn) {
private$appUrl <- appUrl
private$cleanupFn <- cleanupFn
reg.finalizer(self, function(e) {
tryCatch(e$stop(), error = function(cnd) NULL)
}, onexit = TRUE)
},
stop = function() {
if (self$status() != "running") {
return(invisible(self))
}
private$stopped <- TRUE
private$captureResult()
private$cleanupFn()
private$cleanupFn <- NULL
invisible(self)
},
url = function() private$appUrl,
status = function() {
if (!private$stopped) {
"running"
} else if (!is.null(private$resultError)) {
"error"
} else {
"success"
}
},
result = function() {
if (self$status() == "running") {
stop("App is still running. Use status() to check if the app has stopped.")
}
if (!is.null(private$resultError)) {
stop(private$resultError)
}
private$resultValue
},
print = function(...) {
cat("Shiny app handle\n")
cat(" URL: ", private$appUrl, "\n", sep = "")
cat(" Status:", self$status(), "\n")
invisible(self)
}
),
private = list(
appUrl = NULL,
cleanupFn = NULL,
# Whether this handle has been stopped. Distinct from .globals$stopped
# which tracks whether a stop was requested (set by stopApp() or stop()).
stopped = FALSE,
resultValue = NULL,
resultError = NULL,
captureResult = function() {
if (isTRUE(.globals$reterror)) {
private$resultError <- .globals$retval
} else if (!is.null(.globals$retval)) {
private$resultValue <- .globals$retval$value
}
}
)
)

View File

@@ -46,6 +46,12 @@
#' only used for recording or running automated tests. Defaults to the
#' `shiny.testmode` option, or FALSE if the option is not set.
#'
#' @return The value passed to [stopApp()], or throws an error if the app was
#' stopped with an error.
#'
#' @seealso [startApp()] for non-blocking mode, [stopApp()] to stop a running
#' app.
#'
#' @examples
#' \dontrun{
#' # Start app in the current working directory
@@ -93,18 +99,14 @@ runApp <- function(
display.mode=c("auto", "normal", "showcase"),
test.mode=getOption('shiny.testmode', FALSE)
) {
# * Wrap **all** execution of the app inside the otel promise domain
# * While this could be done at a lower level, it allows for _anything_ within
# shiny's control to allow for the opportunity to have otel active spans be
# reactivated upon promise domain restoration
promises::local_otel_promise_domain()
on.exit({
handlerManager$clear()
}, add = TRUE)
if (isRunning()) {
# Check for nested blocking runApp() before sourcing app code
if (isRunning() && is.null(.globals$runningHandle)) {
stop("Can't call `runApp()` from within `runApp()`. If your ",
"application code contains `runApp()`, please remove it.")
}
@@ -116,14 +118,13 @@ runApp <- function(
warn = max(1, getOption("warn", default = 1)),
pool.scheduler = scheduleTask
)
on.exit(options(ops), add = TRUE)
# ============================================================================
# Global onStart/onStop callbacks
# ============================================================================
# Invoke user-defined onStop callbacks, before the application's internal
# onStop callbacks.
on.exit({
# Ensure options are restored and onStop callbacks fire even if
# as.shiny.appobj() errors. Once .setupShinyApp() succeeds, the returned
# cleanup function takes over and this guard becomes a no-op.
setupComplete <- FALSE
on.exit(if (!setupComplete) {
options(ops)
.globals$onStopCallbacks$invoke()
.globals$onStopCallbacks <- Callbacks$new()
}, add = TRUE)
@@ -135,32 +136,140 @@ runApp <- function(
# ============================================================================
appParts <- as.shiny.appobj(appDir)
# ============================================================================
# Initialize app state object
# ============================================================================
# This is so calls to getCurrentAppState() can be used to find (A) whether an
# app is running and (B), get options and data associated with the app.
initCurrentAppState(appParts)
on.exit(clearCurrentAppState(), add = TRUE)
# Any shinyOptions set after this point will apply to the current app only
# (and will not persist after the app stops).
result <- .setupShinyApp(
appDir, appParts, port, launch.browser, host,
workerId, quiet, display.mode, test.mode, ops = ops
)
setupComplete <- TRUE
on.exit(result$cleanup(), add = TRUE)
# ============================================================================
# shinyOptions
# Run event loop via httpuv
# ============================================================================
# A unique identifier associated with this run of this application. It is
# shared across sessions.
shinyOptions(appToken = createUniqueId(8))
# Top-level ..stacktraceoff..; matches with ..stacktraceon in observe(),
# reactive(), Callbacks$invoke(), and others
..stacktraceoff..(
captureStackTraces({
while (!.globals$stopped) {
..stacktracefloor..(serviceApp())
}
})
)
# Set up default cache for app.
if (is.null(getShinyOption("cache", default = NULL))) {
shinyOptions(cache = cachem::cache_mem(max_size = 200 * 1024^2))
if (isTRUE(.globals$reterror)) {
stop(.globals$retval)
} else if (.globals$retval$visible) {
.globals$retval$value
} else {
invisible(.globals$retval$value)
}
}
# Extract appOptions (which is a list) and store them as shinyOptions, for
# this app. (This is the only place we have to store settings that are
# accessible both the UI and server portion of the app.)
applyCapturedAppOptions(appParts$appOptions)
#' Start Shiny Application (Non-Blocking)
#'
#' Starts a Shiny application in non-blocking mode, returning a
#' `ShinyAppHandle` immediately while the app runs in the background.
#' The `later` event loop services the app, so the R console remains
#' available for interaction.
#'
#' @inheritParams runApp
#'
#' @return 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.
#'
#' @examples
#' \dontrun{
#' # Start app in the background
#' handle <- startApp("myapp")
#'
#' # Check status
#' handle$status()
#' handle$url()
#'
#' # Stop the app
#' handle$stop()
#' }
#'
#' @seealso [runApp()] for blocking mode, [stopApp()] to stop a running app.
#' @export
startApp <- function(
appDir = getwd(),
port = getOption("shiny.port"),
launch.browser = getOption("shiny.launch.browser", interactive()),
host = getOption("shiny.host", "127.0.0.1"),
workerId = "",
quiet = FALSE,
display.mode = c("auto", "normal", "showcase"),
test.mode = getOption("shiny.testmode", FALSE)
) {
# OTEL: `local_otel_promise_domain()` ties its lifetime to this frame,
# which exits as soon as the handle is returned — before any request is
# served. A persistent global install would instead leak into unrelated
# user promises between ticks. Wrap the synchronous setup below (covers
# onStart) and each service iteration in `serviceNonBlocking()` (covers
# handlers and observers). The domain is dormant between ticks, so it
# stays out of user promises created at the console.
# Make warnings print immediately
# Set pool.scheduler to support pool package
ops <- options(
# Raise warn level to 1, but don't lower it
warn = max(1, getOption("warn", default = 1)),
pool.scheduler = scheduleTask
)
# Ensure options are restored and onStop callbacks fire even if
# as.shiny.appobj() errors. See matching guard in runApp().
setupComplete <- FALSE
on.exit(if (!setupComplete) {
options(ops)
.globals$onStopCallbacks$invoke()
.globals$onStopCallbacks <- Callbacks$new()
}, add = TRUE)
require(shiny)
result <- promises::with_otel_promise_domain({
appParts <- as.shiny.appobj(appDir)
.setupShinyApp(
appDir, appParts, port, launch.browser, host,
workerId, quiet, display.mode, test.mode, ops = ops
)
})
setupComplete <- TRUE
handle <- ShinyAppHandle$new(result$appUrl, result$cleanup)
.globals$runningHandle <- handle
serviceNonBlocking(handle, .globals$serviceGeneration)
handle
}
# Shared initialization for runApp() and startApp().
# Handles all app setup: options, state, httpuv server, browser launch, etc.
# Returns list(appUrl, cleanup) where cleanup() tears down the app.
# On setup failure, internal on.exit handlers clean up partial state.
.setupShinyApp <- function(appDir, appParts, port, launch.browser, host,
workerId, quiet, display.mode, test.mode, ops,
caller = parent.frame()) {
# Guard on.exit handlers with this flag so they only fire on setup failure.
# On success, cleanup responsibility is handed to the caller via the
# returned cleanup function.
cleanupOnExit <- TRUE
on.exit(if (cleanupOnExit) handlerManager$clear(), add = TRUE)
if (isRunning()) {
if (!is.null(.globals$runningHandle)) {
message("Stopping running Shiny app.")
.globals$runningHandle$stop()
} else {
stop("Can't start a new app while another is running. ",
"If your application code contains `runApp()` or `startApp()`, remove it. ",
"Otherwise, stop the current app first with stopApp().")
}
}
# ============================================================================
# runApp options set via shinyApp(options = list(...))
@@ -182,25 +291,55 @@ runApp <- function(
# | no | yes | use runApp | if it's not missing (runApp specifies), use those |
# | yes | yes | use runApp | if it's not missing (runApp specifies), use those |
#
# I tried to make this as compact and intuitive as possible,
# given that there are four distinct possibilities to check
# `missing()` runs in the caller's frame: with defaults on the outer
# formals, arguments are no longer missing by the time they reach here.
appOps <- appParts$options
findVal <- function(arg, default) {
if (arg %in% names(appOps)) appOps[[arg]] else default
}
if (evalq(missing(port), caller)) port <- findVal("port", port)
if (evalq(missing(launch.browser), caller)) launch.browser <- findVal("launch.browser", launch.browser)
if (evalq(missing(host), caller)) host <- findVal("host", host)
if (evalq(missing(quiet), caller)) quiet <- findVal("quiet", quiet)
if (evalq(missing(display.mode), caller)) display.mode <- findVal("display.mode", display.mode)
if (evalq(missing(test.mode), caller)) test.mode <- findVal("test.mode", test.mode)
if (missing(port))
port <- findVal("port", port)
if (missing(launch.browser))
launch.browser <- findVal("launch.browser", launch.browser)
if (missing(host))
host <- findVal("host", host)
if (missing(quiet))
quiet <- findVal("quiet", quiet)
if (missing(display.mode))
display.mode <- findVal("display.mode", display.mode)
if (missing(test.mode))
test.mode <- findVal("test.mode", test.mode)
on.exit(if (cleanupOnExit) options(ops), add = TRUE)
# ============================================================================
# Global onStart/onStop callbacks
# ============================================================================
on.exit(if (cleanupOnExit) {
.globals$onStopCallbacks$invoke()
.globals$onStopCallbacks <- Callbacks$new()
}, add = TRUE)
# ============================================================================
# Initialize app state object
# ============================================================================
# This is so calls to getCurrentAppState() can be used to find (A) whether an
# app is running and (B), get options and data associated with the app.
initCurrentAppState(appParts)
on.exit(if (cleanupOnExit) clearCurrentAppState(), add = TRUE)
# Any shinyOptions set after this point will apply to the current app only
# (and will not persist after the app stops).
# ============================================================================
# shinyOptions
# ============================================================================
# A unique identifier associated with this run of this application. It is
# shared across sessions.
shinyOptions(appToken = createUniqueId(8))
# Set up default cache for app.
if (is.null(getShinyOption("cache", default = NULL))) {
shinyOptions(cache = cachem::cache_mem(max_size = 200 * 1024^2))
}
# Extract appOptions (which is a list) and store them as shinyOptions, for
# this app. (This is the only place we have to store settings that are
# accessible both the UI and server portion of the app.)
applyCapturedAppOptions(appParts$appOptions)
if (is.null(host) || is.na(host)) host <- '0.0.0.0'
@@ -286,7 +425,7 @@ runApp <- function(
# If display mode is specified as an argument, apply it (overriding the
# value specified in DESCRIPTION, if any).
display.mode <- match.arg(display.mode)
display.mode <- match.arg(display.mode, c("auto", "normal", "showcase"))
if (display.mode == "normal") {
setShowcaseDefault(0)
}
@@ -340,24 +479,21 @@ runApp <- function(
# onStart/onStop callbacks
# ============================================================================
# Set up the onStop before we call onStart, so that it gets called even if an
# error happens in onStart.
# error happens in onStart or later during startup.
if (!is.null(appParts$onStop))
on.exit(appParts$onStop(), add = TRUE)
on.exit(if (cleanupOnExit) appParts$onStop(), add = TRUE)
if (!is.null(appParts$onStart))
appParts$onStart()
# ============================================================================
# Start/stop httpuv app
# Start httpuv app
# ============================================================================
server <- startApp(appParts, port, host, quiet)
server <- startHttpuvApp(appParts, port, host, quiet)
# Make the httpuv server object accessible. Needed for calling
# addResourcePath while app is running.
shinyOptions(server = server)
on.exit({
stopServer(server)
}, add = TRUE)
on.exit(if (cleanupOnExit) stopServer(server), add = TRUE)
# ============================================================================
# Launch web browser
@@ -388,39 +524,52 @@ runApp <- function(
# Application hooks
# ============================================================================
callAppHook("onAppStart", appUrl)
on.exit({
callAppHook("onAppStop", appUrl)
}, add = TRUE)
on.exit(if (cleanupOnExit) callAppHook("onAppStop", appUrl), add = TRUE)
# ============================================================================
# Run event loop via httpuv
# ============================================================================
# Initialize globals used by the event loop and stopApp()
.globals$reterror <- NULL
.globals$retval <- NULL
.globals$stopped <- FALSE
# Top-level ..stacktraceoff..; matches with ..stacktraceon in observe(),
# reactive(), Callbacks$invoke(), and others
..stacktraceoff..(
captureStackTraces({
while (!.globals$stopped) {
..stacktracefloor..(serviceApp())
}
})
)
if (isTRUE(.globals$reterror)) {
stop(.globals$retval)
# Invalidate any stale non-blocking service loops from a previous app.
# Each app launch gets a fresh generation so old callbacks become no-ops.
.globals$serviceGeneration <- (.globals$serviceGeneration %||% 0L) + 1L
# Setup complete - disable on.exit cleanup, hand off to caller
cleanupOnExit <- FALSE
list(
appUrl = appUrl,
cleanup = .createCleanup(server, appParts, appUrl, ops)
)
}
# Consolidated cleanup function for app teardown
.createCleanup <- function(server, appParts, appUrl, ops) {
cleanedUp <- FALSE
function() {
if (cleanedUp) return()
cleanedUp <<- TRUE
.globals$stopped <- TRUE
.globals$runningHandle <- NULL
handlerManager$clear()
options(ops)
.globals$onStopCallbacks$invoke()
.globals$onStopCallbacks <- Callbacks$new()
clearCurrentAppState()
if (!is.null(appParts$onStop)) appParts$onStop()
stopServer(server)
callAppHook("onAppStop", appUrl)
}
else if (.globals$retval$visible)
.globals$retval$value
else
invisible(.globals$retval$value)
}
#' Stop the currently running Shiny app
#'
#' Stops the currently running Shiny app, returning control to the caller of
#' [runApp()].
#' [runApp()]. Despite the similar names, `stopApp()` is not the
#' counterpart of [startApp()] — it is the counterpart of [runApp()],
#' controlling its return value via `returnValue`.
#'
#' @param returnValue The value that should be returned from
#' [runApp()].

View File

@@ -387,7 +387,7 @@ removeSubApp <- function(path) {
handlerManager$removeWSHandler(path)
}
startApp <- function(appObj, port, host, quiet) {
startHttpuvApp <- function(appObj, port, host, quiet) {
appHandlers <- createAppHandlers(appObj$httpHandler, appObj$serverFuncSource)
handlerManager$addHandler(appHandlers$http, "/", tail = TRUE)
handlerManager$addWSHandler(appHandlers$ws, "/", tail = TRUE)
@@ -479,9 +479,12 @@ startApp <- function(appObj, port, host, quiet) {
}
}
# Run an application that was created by \code{\link{startApp}}. This
# Run an application that was created by \code{\link{startHttpuvApp}}. This
# function should normally be called in a \code{while(TRUE)} loop.
serviceApp <- function() {
serviceApp <- function(
# rely on lazy evaluation for maximum efficiency
timeout = max(1, min(maxTimeout, timerCallbacks$timeToNextEvent(), later::next_op_secs()))
) {
timerCallbacks$executeElapsed()
flushReact()
@@ -491,13 +494,58 @@ serviceApp <- function() {
# to keep the session responsive to user input
maxTimeout <- ifelse(interactive(), 100, 1000)
timeout <- max(1, min(maxTimeout, timerCallbacks$timeToNextEvent(), later::next_op_secs()))
service(timeout)
flushReact()
flushPendingSessions()
}
# Non-blocking service loop using later callbacks.
# Uses 1ms delay between iterations to yield CPU for console interaction.
# The generation token (incremented on every runApp() call) ensures that when
# a new app starts, any stale service loop from a previous non-blocking app
# exits cleanly instead of continuing to run.
# Each iteration wraps `serviceApp()` in `with_otel_promise_domain()` so the
# OTEL domain is active while Shiny processes its own work — handlers,
# later callbacks, promise fulfillments — all executed synchronously inside
# `serviceApp()`. Span wrapping is attached at promise-registration time, so
# callbacks registered inside an iteration stay instrumented when they fire
# later. The domain is dormant between ticks, keeping it out of unrelated
# user promises created while the console is interactive.
serviceNonBlocking <- function(handle, generation) {
serviceLoop <- function() {
if (!identical(.globals$serviceGeneration, generation)) {
return(invisible())
}
if (!.globals$stopped) {
promises::with_otel_promise_domain(
..stacktraceoff..(
captureStackTraces(
tryCatch(
..stacktracefloor..(serviceApp(.shinyServiceDelaySecs * 1000)),
error = function(e) {
.globals$stopped <- TRUE
.globals$retval <- e
.globals$reterror <- TRUE
}
)
)
)
)
}
if (!identical(.globals$serviceGeneration, generation)) {
return(invisible())
}
if (!.globals$stopped) {
later::later(serviceLoop, delay = .shinyServiceDelaySecs)
} else {
handle$stop()
}
}
later::later(serviceLoop, delay = .shinyServiceDelaySecs)
}
.shinyServiceDelaySecs <- 0.001
.shinyServerMinVersion <- '0.3.4'
#' Check whether a Shiny application is running

View File

@@ -60,6 +60,10 @@ in its \code{DESCRIPTION} file, if any.}
only used for recording or running automated tests. Defaults to the
\code{shiny.testmode} option, or FALSE if the option is not set.}
}
\value{
The value passed to \code{\link[=stopApp]{stopApp()}}, or throws an error if the app was
stopped with an error.
}
\description{
Runs a Shiny application. This function normally does not return; interrupt R
to stop the application (usually by pressing Ctrl+C or Esc).
@@ -109,3 +113,7 @@ if (interactive()) {
runApp(app)
}
}
\seealso{
\code{\link[=startApp]{startApp()}} for non-blocking mode, \code{\link[=stopApp]{stopApp()}} to stop a running
app.
}

91
man/startApp.Rd Normal file
View File

@@ -0,0 +1,91 @@
% Generated by roxygen2: do not edit by hand
% Please edit documentation in R/runapp.R
\name{startApp}
\alias{startApp}
\title{Start Shiny Application (Non-Blocking)}
\usage{
startApp(
appDir = getwd(),
port = getOption("shiny.port"),
launch.browser = getOption("shiny.launch.browser", interactive()),
host = getOption("shiny.host", "127.0.0.1"),
workerId = "",
quiet = FALSE,
display.mode = c("auto", "normal", "showcase"),
test.mode = getOption("shiny.testmode", FALSE)
)
}
\arguments{
\item{appDir}{The application to run. Should be one of the following:
\itemize{
\item A directory containing \code{server.R}, plus, either \code{ui.R} or
a \code{www} directory that contains the file \code{index.html}.
\item A directory containing \code{app.R}.
\item An \code{.R} file containing a Shiny application, ending with an
expression that produces a Shiny app object.
\item A list with \code{ui} and \code{server} components.
\item A Shiny app object created by \code{\link[=shinyApp]{shinyApp()}}.
}}
\item{port}{The TCP port that the application should listen on. If the
\code{port} is not specified, and the \code{shiny.port} option is set (with
\code{options(shiny.port = XX)}), then that port will be used. Otherwise,
use a random port between 3000:8000, excluding ports that are blocked
by Google Chrome for being considered unsafe: 3659, 4045, 5060,
5061, 6000, 6566, 6665:6669 and 6697. Up to twenty random
ports will be tried.}
\item{launch.browser}{If true, the system's default web browser will be
launched automatically after the app is started. Defaults to true in
interactive sessions only. The value of this parameter can also be a
function to call with the application's URL.}
\item{host}{The IPv4 address that the application should listen on. Defaults
to the \code{shiny.host} option, if set, or \code{"127.0.0.1"} if not. See
Details.}
\item{workerId}{Can generally be ignored. Exists to help some editions of
Shiny Server Pro route requests to the correct process.}
\item{quiet}{Should Shiny status messages be shown? Defaults to FALSE.}
\item{display.mode}{The mode in which to display the application. If set to
the value \code{"showcase"}, shows application code and metadata from a
\code{DESCRIPTION} file in the application directory alongside the
application. If set to \code{"normal"}, displays the application normally.
Defaults to \code{"auto"}, which displays the application in the mode given
in its \code{DESCRIPTION} file, if any.}
\item{test.mode}{Should the application be launched in test mode? This is
only used for recording or running automated tests. Defaults to the
\code{shiny.testmode} option, or FALSE if the option is not set.}
}
\value{
A \code{ShinyAppHandle} object with methods \code{stop()}, \code{status()},
\code{url()}, and \code{result()}. The \code{status()} method returns \code{"running"},
\code{"success"}, or \code{"error"}. The \code{result()} method throws an error if called
while running, or re-throws the error if the app stopped with an error.
}
\description{
Starts a Shiny application in non-blocking mode, returning a
\code{ShinyAppHandle} immediately while the app runs in the background.
The \code{later} event loop services the app, so the R console remains
available for interaction.
}
\examples{
\dontrun{
# Start app in the background
handle <- startApp("myapp")
# Check status
handle$status()
handle$url()
# Stop the app
handle$stop()
}
}
\seealso{
\code{\link[=runApp]{runApp()}} for blocking mode, \code{\link[=stopApp]{stopApp()}} to stop a running app.
}

View File

@@ -12,5 +12,7 @@ stopApp(returnValue = invisible())
}
\description{
Stops the currently running Shiny app, returning control to the caller of
\code{\link[=runApp]{runApp()}}.
\code{\link[=runApp]{runApp()}}. Despite the similar names, \code{stopApp()} is not the
counterpart of \code{\link[=startApp]{startApp()}} — it is the counterpart of \code{\link[=runApp]{runApp()}},
controlling its return value via \code{returnValue}.
}

View File

@@ -0,0 +1,275 @@
# Prevent browser launch in interactive sessions
withr::local_options(list(shiny.launch.browser = FALSE), .local_envir = teardown_env())
test_that("ShinyAppHandle lifecycle and API (success path)", {
app <- shinyApp(
ui = fluidPage(),
server = function(input, output) {}
)
handle <- startApp(app, launch.browser = FALSE, quiet = TRUE)
# While running
expect_equal(handle$status(), "running")
expect_match(handle$url(), "^http://")
expect_error(handle$result(), "App is still running")
output <- capture.output(print(handle))
expect_match(output[1], "Shiny app handle")
expect_match(output[2], "URL:")
expect_match(output[3], "running")
# stop() returns invisible self
ret <- withVisible(handle$stop())
expect_false(ret$visible)
expect_identical(ret$value, handle)
# After stop
expect_equal(handle$status(), "success")
expect_null(handle$result())
output <- capture.output(print(handle))
expect_match(output[3], "success")
# Double stop is a silent no-op
expect_no_warning(handle$stop())
expect_equal(handle$status(), "success")
})
test_that("ShinyAppHandle lifecycle (error path)", {
app <- shinyApp(
ui = fluidPage(),
server = function(input, output) {}
)
handle <- startApp(app, launch.browser = FALSE, quiet = TRUE)
stopApp(stop("test_error", call. = FALSE))
while (handle$status() == "running") {
later::run_now(timeoutSecs = 1)
}
expect_equal(handle$status(), "error")
expect_error(handle$result(), "test_error")
output <- capture.output(print(handle))
expect_match(output[3], "error")
})
test_that("handle captures result from stopApp", {
app <- shinyApp(
ui = fluidPage(),
server = function(input, output) {}
)
handle <- startApp(app, launch.browser = FALSE, quiet = TRUE)
stopApp("test_result")
while (handle$status() == "running") {
later::run_now(timeoutSecs = 1)
}
expect_equal(handle$status(), "success")
expect_equal(handle$result(), "test_result")
})
test_that("non-blocking auto-stops previous app when starting new one", {
app1 <- shinyApp(
ui = fluidPage(),
server = function(input, output) {}
)
app2 <- shinyApp(
ui = fluidPage(),
server = function(input, output) {}
)
handle1 <- startApp(app1, launch.browser = FALSE, quiet = TRUE)
expect_equal(handle1$status(), "running")
# Starting a second non-blocking app should auto-stop the first
handle2 <- startApp(app2, launch.browser = FALSE, quiet = TRUE)
on.exit(handle2$stop(), add = TRUE)
expect_equal(handle1$status(), "success")
expect_equal(handle2$status(), "running")
handle2$stop()
})
test_that("replacing a non-blocking app does not leave stale service loops", {
generations_seen <- integer(0)
# Mock serviceApp to record which generation is active when called
local_mocked_bindings(
serviceApp = function(timeout) {
generations_seen[[length(generations_seen) + 1L]] <<-
.globals$serviceGeneration
},
.package = "shiny"
)
app1 <- shinyApp(ui = fluidPage(), server = function(input, output) {})
app2 <- shinyApp(ui = fluidPage(), server = function(input, output) {})
handle1 <- startApp(app1, launch.browser = FALSE, quiet = TRUE)
gen1 <- .globals$serviceGeneration
handle2 <- startApp(app2, launch.browser = FALSE, quiet = TRUE)
on.exit(handle2$stop(), add = TRUE)
gen2 <- .globals$serviceGeneration
# Reset and let service loops run
generations_seen <- integer(0)
while (length(generations_seen) < 5L) later::run_now(timeoutSecs = 1)
# Only the new generation should be servicing
expect_true(length(generations_seen) > 0)
expect_true(all(generations_seen == gen2))
handle2$stop()
})
test_that("starting a blocking app invalidates stale non-blocking service loops", {
service_calls <- 0L
local_mocked_bindings(
serviceApp = function(timeout) {
service_calls <<- service_calls + 1L
},
.package = "shiny"
)
ns <- asNamespace("shiny")
g <- get(".globals", envir = ns)
# Simulate a non-blocking app at generation 1
assign("serviceGeneration", 1L, envir = g)
assign("stopped", FALSE, envir = g)
shiny:::serviceNonBlocking(list(stop = function() {}), 1L)
# Simulate stopping app 1, then starting a blocking app which bumps generation
assign("stopped", TRUE, envir = g)
assign("serviceGeneration", 2L, envir = g)
assign("stopped", FALSE, envir = g)
later::run_now(timeoutSecs = 1)
expect_equal(service_calls, 0L)
})
test_that("nested runApp in blocking mode still errors", {
inner_app <- shinyApp(
ui = fluidPage(),
server = function(input, output) {}
)
outer_app <- shinyApp(
ui = fluidPage(),
server = function(input, output) {},
onStart = function() {
runApp(inner_app, launch.browser = FALSE, quiet = TRUE)
}
)
expect_error(
runApp(outer_app, launch.browser = FALSE, quiet = TRUE),
"from within `runApp"
)
})
test_that("cleanup callbacks run when stopped", {
stopped <- FALSE
app <- shinyApp(
ui = fluidPage(),
server = function(input, output) {}
)
onStop(function() stopped <<- TRUE)
handle <- startApp(app, launch.browser = FALSE, quiet = TRUE)
handle$stop()
expect_true(stopped)
})
test_that("old handle doesn't see new app's result", {
app1 <- shinyApp(
ui = fluidPage(),
server = function(input, output) {}
)
handle1 <- startApp(app1, launch.browser = FALSE, quiet = TRUE)
stopApp("result1")
while (handle1$status() == "running") {
later::run_now(1)
}
expect_equal(handle1$result(), "result1")
# Start and stop app2
app2 <- shinyApp(
ui = fluidPage(),
server = function(input, output) {}
)
handle2 <- startApp(app2, launch.browser = FALSE, quiet = TRUE)
stopApp("result2")
while (handle2$status() == "running") {
later::run_now(timeoutSecs = 1)
}
expect_equal(handle2$result(), "result2")
# handle1 should still have its original result
expect_equal(handle1$result(), "result1")
})
test_that("global isRunning() works with non-blocking apps", {
app <- shinyApp(
ui = fluidPage(),
server = function(input, output) {}
)
expect_false(isRunning())
handle <- startApp(app, launch.browser = FALSE, quiet = TRUE)
on.exit(handle$stop(), add = TRUE)
expect_true(isRunning())
handle$stop()
expect_false(isRunning())
})
test_that("startup failure clears app state (regression test)", {
# If startup fails after initCurrentAppState() but before cleanupOnExit <- FALSE,
# the app state must be cleared so subsequent runApp() calls don't fail with
# "Can't start a new app while another is running"
# Create an app that fails during onStart (which runs after initCurrentAppState)
failing_app <- shinyApp(
ui = fluidPage(),
server = function(input, output) {},
onStart = function() stop("Intentional startup failure")
)
# This should fail
expect_error(
startApp(failing_app, launch.browser = FALSE, quiet = TRUE),
"Intentional startup failure"
)
# isRunning() should return FALSE - no app is actually running
expect_false(isRunning())
# A subsequent runApp() call should work
working_app <- shinyApp(
ui = fluidPage(),
server = function(input, output) {}
)
handle <- startApp(working_app, launch.browser = FALSE, quiet = TRUE)
on.exit(handle$stop(), add = TRUE)
expect_equal(handle$status(), "running")
handle$stop()
})

View File

@@ -139,6 +139,7 @@ reference:
desc: Functions that are used to run or stop Shiny applications.
contents:
- runApp
- startApp
- runGadget
- runExample
- runGadget