Skip to content

feat: Add hard=True to session.close() for terminal session ends#2271

Draft
elnelson575 wants to merge 1 commit into
mainfrom
feat/hard-disconnect
Draft

feat: Add hard=True to session.close() for terminal session ends#2271
elnelson575 wants to merge 1 commit into
mainfrom
feat/hard-disconnect

Conversation

@elnelson575

@elnelson575 elnelson575 commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an opt-in hard-disconnect path to session.close() for ending a session terminally from server code — typically in response to a user action like Submit or Log Out:

@reactive.effect
@reactive.event(input.submit)
async def _():
    await session.close(hard=True, message="Thanks for your submission.")

A hard close performs three coordinated tiers:

  1. Wire protocol — sends a hardDisconnectConfig custom message carrying the closed-overlay text, then closes the websocket with code 4001 / reason shiny-hard-disconnect. Hosting platforms (Shiny Server, Posit Connect) can recognize this as "release the worker without holding for reconnect."
  2. In-process cleanup — beyond the existing destroy walk, also clears root-level input, _downloads, _dynamic_routes, and _message_handlers. on_ended callbacks fire before this cleanup, so teardown code can still read session state.
  3. Client UX — the matching client-side handling (recognizing 4001, stashing the hardDisconnectConfig message, rendering a distinct closed-state overlay, firing shiny:closed) lives in R Shiny's srcts/ and reaches py-shiny when shiny.js is vendored from a release that includes it. Until then the server side runs as designed; the browser still sees today's grey "disconnected" overlay.

App-level default text is configurable: App(hard_disconnect_message="…"). Fallback chain: per-call message → app default → "This app has closed.".

Backward compatibility: existing callers of session.close() with no arguments are unaffected — soft close is byte-for-byte unchanged.

This is the py-shiny port of "Plan A" from the upstream hard-disconnect design (R shiny feat/hard-disconnect). The idle-timeout variant ("Plan B") is out of scope for this PR.

Changes

  • shiny/_app.py — new hard_disconnect_message: str | None kwarg on App.__init__
  • shiny/session/_session.pySession.close() gains hard and message kwargs; AppSession.close() implements the hard path; SessionProxy.close() forwards; new AppSession._hard_close_cleanup(); new module-level constants HARD_DISCONNECT_CLOSE_CODE, HARD_DISCONNECT_REASON, HARD_DISCONNECT_DEFAULT_MESSAGE
  • shiny/express/_stub_session.py — signature parity
  • tests/pytest/test_hard_disconnect.py — 13 unit tests
  • CHANGELOG.md — entry under [UNRELEASED]

Test Plan

  • pytest tests/pytest/test_hard_disconnect.py — 13 passed
  • pytest tests/pytest/test_destroy.py tests/pytest/test_hard_disconnect.py — 88 passed (no regression in adjacent destroy/teardown tests)
  • pytest tests/pytest/ --ignore=tests/pytest/test_theme.py — 698 passed (theme tests need libsass, pre-existing local env issue)
  • pyright shiny/session/_session.py shiny/express/_stub_session.py tests/pytest/test_hard_disconnect.py — 0 errors
  • black / isort clean on changed files
  • End-to-end browser verification deferred — requires the upstream R Shiny client-side work to be vendored. Server-side wire-protocol behavior (custom message + 4001) is fully covered by the unit tests.

Out of scope

  • Idle-timeout (hard_disconnect_after) — separate follow-up
  • Client-side TypeScript/SCSS — lives in R Shiny's srcts/, arrives via make upgrade-html-deps
  • Hosting-platform changes in Shiny Server / Connect — separate cross-team work item; py-shiny just emits the contract

Adds an opt-in hard-disconnect path to `session.close()` and a matching
`hard_disconnect_message` argument on `App` for the default closed-overlay
text. Hard close performs three coordinated tiers:

1. Sends a `hardDisconnectConfig` custom message carrying the closed-
   overlay text (resolved as per-call `message` → app default →
   `"This app has closed."`).
2. Closes the websocket with code `4001` and reason
   `shiny-hard-disconnect` so hosting platforms can recognize "release
   this worker without holding for reconnect."
3. Additionally clears the root-level `input`, `_downloads`,
   `_dynamic_routes`, and `_message_handlers` collections that a soft
   close leaves in place. `on_ended` callbacks fire before the hard
   cleanup, so teardown code can still read session state.

Default behavior is unchanged: existing callers of `session.close()` with
no arguments behave exactly as today. The matching client-side handling
(4001 recognition, `hardDisconnectConfig` custom-message handler,
distinct closed-state overlay) lives in R shiny's srcts and reaches
py-shiny when shiny.js is vendored from a release that includes it.
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