feat: opt-in hard-disconnect via session$close(hard = TRUE)#4398
Draft
elnelson575 wants to merge 20 commits into
Draft
feat: opt-in hard-disconnect via session$close(hard = TRUE)#4398elnelson575 wants to merge 20 commits into
elnelson575 wants to merge 20 commits into
Conversation
Captures the design for shinyApp(hardDisconnect = TRUE): in-process teardown of root inputs/clientData/downloads/files, handshake-time hosting-layer signal, and a distinct client-side closed state. Includes the brief for the Shiny Server / Connect teams describing the contract they would need to honor.
…meout Captures the design for two opt-in hard-disconnect pathways in Shiny: - session$close(hard = TRUE, message = ...) for author-triggered ends - shinyApp(hardDisconnectAfter = N) for idle timeout No app-wide "every disconnect is hard" mode; the natural-disconnect path stays soft. Also elevates session$close() to documented public API. Covers the in-process teardown of root inputs/clientData/ downloads/files, the 4001 close code as the hosting-layer signal, and a distinct client-side closed state, plus a brief for the Shiny Server / Connect teams describing the contract they would need to honor.
After reviewing shiny-server's architecture (specifically lib/scheduler/worker-entry.js), confirm the idle-timeout belongs in Shiny rather than delegated to the platform: shiny-server tracks connection counts, not per-session activity, and adding active-idle tracking would require substantial new machinery in the proxy. Captures this in a "Why Shiny implements this" subsection and sharpens the hosting handoff brief with the concrete code change needed in shiny-server (treat close code 4001 as equivalent to app_idle_timeout = 0 for that worker).
Tasks for the per-call hard-close primitive: hardDisconnectMessage arg on shinyApp(), session$close(hard, message), wsClosed teardown extension, custom-message handler + onclose 4001 check on the client, closed-overlay SCSS, NEWS entry. Plan B (idle timeout) deferred to a follow-up branch off this work.
Add private fields hardDisconnectMessage and wasHardClose to ShinySession. Read hardDisconnectMessage from shinyOptions during initialize().
Replace withr::with_options(list(), {...}) wrappers with withr::defer()
calls. The with_options wrapper only affects base::options(), not
shinyOptions which are stored in .globals$options. Using withr::defer()
to reset the shinyOption at end-of-test provides actual cleanup isolation.
…them
Add clarifying comment explaining why destroyByPrefix("") is used instead
of destroy() (avoids marking the container as destroyed). In the test,
pre-populate .input and .clientData so the expect_length assertions are
non-vacuous and actually verify that destroyByPrefix("") clears the keys.
Add CSS for the closed overlay introduced in Task 8, using a fully opaque white card centered on screen to communicate finality, visually distinct from the transient grey-haze disconnected overlay.
Move hardDisconnectConfig from the user-facing addCustomMessageHandler path (where a user app could silently override Shiny's handler by registering a custom handler with the same name) to the internal addMessageHandler path used for other built-in protocol messages like allowReconnect. addMessageHandler throws on duplicate registration, protecting the feature from accidental collision.
Adds browser-driven tests covering what the R unit tests can't: - close_hard renders #shiny-closed-overlay with the per-call message - close_app_default falls back to shinyApp(hardDisconnectMessage =) - close_soft preserves today's #shiny-disconnected-overlay (regression) - submit-and-exit pattern: modal first, then closed overlay - shiny:closed jQuery event fires with the correct detail.message Listens via jQuery .on() rather than document.addEventListener because $(document).trigger(...) only reaches jQuery handlers — consistent with the rest of Shiny's event surface.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an opt-in hard-disconnect mode to Shiny. App authors can now call
session$close(hard = TRUE, message = ...)to perform a complete session teardown — useful for "submit and exit" / "log out" flows and end-user-visible "this app has closed" experiences. The teardown runs in three coordinated tiers:wsClosed()clears the rootinputs,clientData,downloads, andfilescollections (which the existing destroy walk intentionally skips for the soft path) and drops theSessionProxyreference.4001and reason"shiny-hard-disconnect", which Shiny Server / Posit Connect can recognize as "release this worker, no reconnect grace period."#shiny-closed-overlayreplaces the grey#shiny-disconnected-overlay; the page becomes inert; ashiny:closedjQuery event fires with the author's message indetail.message; iframe parents receive"closed"postMessage.Default text for the closed overlay can be set app-wide via
shinyApp(hardDisconnectMessage = ...)and overridden per-call. Default behavior is unchanged —session$close()with no arguments performs today's soft close byte-for-byte.This is Plan A of a two-part split. Plan B (idle timeout via
shinyApp(hardDisconnectAfter = N)) will follow on a separate branch off this work, building on this primitive — Plan A ships standalone.Design and plan documents
Spec:
docs/superpowers/specs/2026-06-03-hard-disconnect-design.mdImplementation plan:
docs/superpowers/plans/2026-06-03-hard-disconnect-per-call.mdShiny Server / Connect coordination
The spec includes a brief asking the hosting platforms to honor close code
4001as equivalent toapp_idle_timeout = 0for that worker (one-line change inlib/scheduler/worker-entry.js'sstartIdleTimer()). Without this change, in-process teardown and client UX still work; only the worker-pool release optimization is partial. The brief is in the spec, ready to share.Notable deviation from spec
Spec described the runtime config message under the
customenvelope ({"custom":{"hardDisconnectConfig":...}}). Final implementation sends it at the top level ({"hardDisconnectConfig":...}) and registers via internaladdMessageHandlerinstead of user-facingaddCustomMessageHandler— that protects the handler from being silently overridden by a user app registering a custom handler with the same name. Caught during code review (final-pass I1), wire format verified with a real WebSocket driver before merge.Test plan
tests/testthat/test-hard-disconnect.R)srcts/src/shiny/__tests__/hardDisconnect.test.ts)tests/testthat/test-hard-disconnect-shinytest2.Rcovering closed-overlay rendering, app-default fallback, soft-close regression, and the submit-and-exit patternR CMD check: 0 errors, 0 warningshardDisconnectConfig+ close code4001on hard close, code1000+ empty payload on soft close (unchanged from today)#shiny-disconnected-overlaystill appears on natural disconnects andsession$close()(no args)Reviewer notes
shiny:closedis a jQuery event ($(document).trigger(...)). Listeners usingdocument.addEventListenerwill not receive it — same convention as existingshiny:connected/shiny:disconnected. Worth a doc mention in NEWS or session help if the team wants vanilla-JS listeners to be discoverable; left out of this PR to keep scope tight.devtools::install()) for shinytest2's child process to pick up the new arguments — same requirement as the existingtest-zzz-st2-download.R.