Skip to content

feat: opt-in hard-disconnect via session$close(hard = TRUE)#4398

Draft
elnelson575 wants to merge 20 commits into
mainfrom
feat/hard-disconnect
Draft

feat: opt-in hard-disconnect via session$close(hard = TRUE)#4398
elnelson575 wants to merge 20 commits into
mainfrom
feat/hard-disconnect

Conversation

@elnelson575

Copy link
Copy Markdown
Contributor

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:

  1. In-processwsClosed() clears the root inputs, clientData, downloads, and files collections (which the existing destroy walk intentionally skips for the soft path) and drops the SessionProxy reference.
  2. Hosting layer signal — the websocket closes with application close code 4001 and reason "shiny-hard-disconnect", which Shiny Server / Posit Connect can recognize as "release this worker, no reconnect grace period."
  3. Client UX — a distinct #shiny-closed-overlay replaces the grey #shiny-disconnected-overlay; the page becomes inert; a shiny:closed jQuery event fires with the author's message in detail.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 unchangedsession$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.md
Implementation plan: docs/superpowers/plans/2026-06-03-hard-disconnect-per-call.md

Shiny Server / Connect coordination

The spec includes a brief asking the hosting platforms to honor close code 4001 as equivalent to app_idle_timeout = 0 for that worker (one-line change in lib/scheduler/worker-entry.js's startIdleTimer()). 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 custom envelope ({"custom":{"hardDisconnectConfig":...}}). Final implementation sends it at the top level ({"hardDisconnectConfig":...}) and registers via internal addMessageHandler instead of user-facing addCustomMessageHandler — 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

  • R unit tests: 2452 PASS, 0 FAIL (added 11 R unit assertions in tests/testthat/test-hard-disconnect.R)
  • TypeScript unit tests: 5/5 pass (srcts/src/shiny/__tests__/hardDisconnect.test.ts)
  • shinytest2 browser-driven tests: 11 assertions across 4 scenarios in tests/testthat/test-hard-disconnect-shinytest2.R covering closed-overlay rendering, app-default fallback, soft-close regression, and the submit-and-exit pattern
  • R CMD check: 0 errors, 0 warnings
  • Wire format verified end-to-end with a raw WebSocket driver against a live server: top-level hardDisconnectConfig + close code 4001 on hard close, code 1000 + empty payload on soft close (unchanged from today)
  • Soft path regression check: today's #shiny-disconnected-overlay still appears on natural disconnects and session$close() (no args)

Reviewer notes

  • shiny:closed is a jQuery event ($(document).trigger(...)). Listeners using document.addEventListener will not receive it — same convention as existing shiny: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.
  • The shinytest2 test file requires the dev shiny to be installed (devtools::install()) for shinytest2's child process to pick up the new arguments — same requirement as the existing test-zzz-st2-download.R.

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.
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.

1 participant