Skip to content

server(data_source = ) is unsafe for concurrent sessions (R and Python) #300

Description

@cpsievert

Summary

Some deployments can only create a table's data source inside the Shiny server function — for example, on Posit Connect, a per-user database connection (e.g. Snowflake via PositAuthenticator) depends on that user's session-scoped OAuth credentials, which aren't available until the server function runs. Both R and Python have a server(data_source = ) pattern meant to support exactly this: register a table lazily, once the session starts, instead of at app-startup time.

That pattern is currently broken in both languages — not just missing in Python, but genuinely unsafe for the concurrent-session case it exists for.

Example

qc <- QueryChat$new(NULL, table_name = "orders")  # no data source yet

server <- function(input, output, session) {
  conn <- DBI::dbConnect(..., token = session_user_token(session))  # per-user connection
  qc$server(data_source = conn)
}
  • User A opens the app. Session A's server() runs and registers conn_A under "orders". Works.
  • User B opens the app — a second browser tab, or simply a second person hitting the same running process. Session B's server() runs and registers conn_B under the same name.

Depending on the language, and how far you dig, different things go wrong here:

  1. It can outright fail for the second session. Registering a table checks a guard meant to stop you from reconfiguring tables once a conversation is already underway — but that guard trips on any second session, not just a manual reconfiguration. In R, this means session B's qc$server(data_source = conn_B) call aborts with "Cannot add tables after server initialization." (Python's server() sidesteps this today only because it dropped the data_source parameter entirely — see History below.)

  2. Even once that's bypassed, it can corrupt session A. "Registering a table under a name that's already taken" is implemented as "replace the old one," which includes cleaning up the old table's resources — closing a database connection, disposing an engine, etc. If session A is still running when session B registers conn_B under "orders", that cleanup closes conn_A — session A's own connection — out from under it. Session A's next query then fails, even though session A did nothing wrong and has no relationship to session B.

  3. The welcome greeting can also leak between sessions. When no explicit greeting is provided, one is generated by asking the LLM to describe the registered table(s) — asynchronously, after the server function has already returned. If session B registers its table in the gap between session A's server function returning and session A's greeting actually being generated, session A's greeting can end up describing session B's table instead of its own.

History / how this was found

  • Python's server() had a data_source parameter for exactly this deferred pattern (added in feat: Allow deferred data_source initialization #202), which the multi-table redesign (feat: multi-table support and data dictionary #195) removed — the breaking-changes table maps it to add_table() before server(), which doesn't support the per-session case at all.
  • R kept $server(data_source = ) through the same redesign (including greeting integration in ad0d6952) — but it was never actually usable for more than one session, because of issue (1) above: R's add_table() (which $server() calls internally) has the identical "no changes after the server has initialized" guard, and that guard flips to TRUE on the very first session.
  • Digging into why that guard matters at all surfaced issue (2): "replace" always assumes the old value is safe to throw away — true when reconfiguring a single shared table before anyone's connected, false when a second session is quietly swapping in its own resource under the same name.
  • Investigating the greeting path surfaced issue (3): greeting generation reads shared, mutable state asynchronously, well after the point where it was last known to be correct for a particular session.

What "fixed" means here

For both R and Python, per-session table registration via server(data_source = ) needs to:

  1. Not be blocked by the fact that a previous session already registered a table.
  2. Not tear down a resource that a different, still-running session is using — that becomes the responsibility of whoever created that resource (e.g. closing it themselves when their own session ends), not something querychat does automatically.
  3. Generate any state that depends on "which table is registered" (like the auto-generated greeting) from what was true for the session that triggered it, not from whatever happens to be live by the time async work actually runs.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions