Skip to content

Commit 929143f

Browse files
Add startApp() for non-blocking app execution (#4349)
* Non-blocking concept * Update unrelated tabPanel test snapshot * Review user interface * Review internal methods * Tighten later loop for performance * Silence tests * Cleanup pass * Rename internal function * Use combined error message * Consolidate two on.exit() clauses * Fix cleanup step and add regression test * Add documentation about running the later loop * Default to non-blocking for LLMs * Auto-stop upon new `runApp()` * Update news and docs * Refactor pass * Make `handle$stop()` idempotent * Simplify `serviceAsync()` * Minimize changes to `on.exit()` handlers * Move cleanup func creation * Consistent cleanup order * Apply review changes from @cpsievert and @schloerke * Make non-default for LLMs * `devtools::document()` (GitHub Actions) * Update news * Use generation invalidation for loops * Move generation increment before the blocking/non-blocking branch * Have `handle$stop()` method handle setting of `stopped` flag * Amend currently running message * Apply suggestions from code review Co-authored-by: Carson Sievert <cpsievert1@gmail.com> * Separate out non-blocking into `startApp()` * Amend `stopApp()` docs * Add `startApp()` to pkgdown.yml * Fix stale variable name in test comment * 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. * 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. * fix(startApp): preserve stopApp() called during setup * docs(startApp): clarify relationship to stopApp() * `devtools::document()` (GitHub Actions) * perf(startApp): replace 1ms polling with adaptive serviceApp(NA) scheduling * `usethis::use_tidy_description()` (GitHub Actions) * fix(startApp): error on nested launch from app code * Add comment about finalizer reachability * Add note in docs about auto-replacement * Update news --------- Co-authored-by: shikokuchuo <shikokuchuo@users.noreply.github.com> Co-authored-by: Carson Sievert <cpsievert1@gmail.com>
1 parent ab02739 commit 929143f

11 files changed

Lines changed: 880 additions & 85 deletions

File tree

DESCRIPTION

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ Config/testthat/edition: 3
127127
Encoding: UTF-8
128128
Roxygen: list(markdown = TRUE)
129129
Collate:
130+
'app-handle.R'
130131
'globals.R'
131132
'app-state.R'
132133
'app_template.R'

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ export(snapshotPreprocessInput)
276276
export(snapshotPreprocessOutput)
277277
export(span)
278278
export(splitLayout)
279+
export(startApp)
279280
export(stopApp)
280281
export(strong)
281282
export(submitButton)

NEWS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## New features
44

5+
* New `startApp()` runs a Shiny app in non-blocking mode, returning a
6+
`ShinyAppHandle` object with `stop()`, `status()`, `url()`, and `result()`
7+
methods. When a new app is started, any previously running non-blocking app
8+
is automatically stopped. (#4349)
9+
510
* `downloadButton()` and `downloadLink()` gain a new `enabled` parameter. The default value, `"auto"`, automatically enables the button/link when the download is ready. To opt-into manual state management (e.g., `shinyjs::enable()`), set `enabled` to `FALSE` (or `TRUE`). (#4119)
611

712
## Improvements

R/app-handle.R

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Handle returned by startApp()
2+
ShinyAppHandle <- R6::R6Class("ShinyAppHandle",
3+
cloneable = FALSE,
4+
5+
public = list(
6+
initialize = function(appUrl, cleanupFn) {
7+
private$appUrl <- appUrl
8+
private$cleanupFn <- cleanupFn
9+
10+
# Does NOT fire while the app is running: internal refs
11+
# (.globals$runningHandle and the serviceLoop closure) keep the
12+
# handle alive.
13+
reg.finalizer(self, function(e) {
14+
tryCatch(e$stop(), error = function(cnd) NULL)
15+
}, onexit = TRUE)
16+
},
17+
18+
stop = function() {
19+
if (self$status() != "running") {
20+
return(invisible(self))
21+
}
22+
private$stopped <- TRUE
23+
private$captureResult()
24+
private$cleanupFn()
25+
private$cleanupFn <- NULL
26+
invisible(self)
27+
},
28+
29+
url = function() private$appUrl,
30+
31+
status = function() {
32+
if (!private$stopped) {
33+
"running"
34+
} else if (!is.null(private$resultError)) {
35+
"error"
36+
} else {
37+
"success"
38+
}
39+
},
40+
41+
result = function() {
42+
if (self$status() == "running") {
43+
stop("App is still running. Use status() to check if the app has stopped.")
44+
}
45+
if (!is.null(private$resultError)) {
46+
stop(private$resultError)
47+
}
48+
private$resultValue
49+
},
50+
51+
print = function(...) {
52+
cat("Shiny app handle\n")
53+
cat(" URL: ", private$appUrl, "\n", sep = "")
54+
cat(" Status:", self$status(), "\n")
55+
invisible(self)
56+
}
57+
),
58+
59+
private = list(
60+
appUrl = NULL,
61+
cleanupFn = NULL,
62+
# Whether this handle has been stopped. Distinct from .globals$stopped
63+
# which tracks whether a stop was requested (set by stopApp() or stop()).
64+
stopped = FALSE,
65+
resultValue = NULL,
66+
resultError = NULL,
67+
68+
captureResult = function() {
69+
if (isTRUE(.globals$reterror)) {
70+
private$resultError <- .globals$retval
71+
} else if (!is.null(.globals$retval)) {
72+
private$resultValue <- .globals$retval$value
73+
}
74+
}
75+
)
76+
)

0 commit comments

Comments
 (0)