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

1 participant