Skip to content

feat(reactive): Deprecate reactive.lock() - #2520

Draft
jat255 wants to merge 3 commits into
schloerke/async-run-once-when-idlefrom
jat255/698-lock-flush-replacement
Draft

jat255 wants to merge 3 commits into
schloerke/async-run-once-when-idlefrom
jat255/698-lock-flush-replacement

Conversation

@jat255

@jat255 jat255 commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

After #2508, Shiny does not take reactive.lock(), so the lock protects nothing. This PR deprecates it and documents what to do instead: set the reactive value directly.

Stacked on #2517 (which is stacked on #2515 and #2508). It closes the #2508 TODO "What replaces reactive.lock() + await reactive.flush()".

Summary

  • reactive.lock() is deprecated (_core.py). Each call emits a ShinyDeprecationWarning. The warning names the replacements and says that the function will be removed. The function returns an asyncio.Lock subclass that never blocks. The return type stays asyncio.Lock, so type checks and isinstance() continue to work.
  • The replacement: set the value directly. Setting a value schedules a flush, so await reactive.flush() is not necessary after it. To apply a change only after a session's running effects finish, use session.run_once_when_idle() (feat(session): Add session.run_once_when_idle() #2517).
  • reactive.flush() does not change. Its docstring now says that app code does not need it after set(), and that it waits on the effects of all sessions.
  • Docs:

Review Notes

  • The lock is a no-op. Code that uses reactive.lock() as a general mutex does not get mutual exclusion anymore. Shiny itself has not used the lock since feat: Run reactive effects #2508. We chose a no-op instead of a working lock that warns, because the lock does not protect reactive state. If it continued to exclude other holders, users could think that it still does. The CHANGELOG tells code that needs a mutex to create its own asyncio.Lock.
  • No API to wait for a session. I did not add a way to wait until one session's cycle is complete (for example, an awaitable run_once_when_idle()). R has no equivalent. The only use I found for the wait was in examples that the old docs told users to write.
  • A deferred import. lock() imports warn_deprecated inside the function, because shiny/_deprecated.py imports shiny.reactive.
  • Left out, with TODOs in feat: Run reactive effects #2508:
    • a built-in helper for async producers that run without a session (Design pattern for global async reactive #698);
    • updates to py-shiny-site (genai-tools.qmd uses the old pattern);
    • an update to the shinychat _history.py docstring, which relies on the old process-wide lock.

Testing

  • New tests in test_concurrency.py:
    • lock() warns on each call, and the warning points to the caller.
    • Two holders can be inside the lock at the same time. The object is an asyncio.Lock, and an exception raised inside async with propagates.
    • A value set from a background task with no session re-runs the effect of a session.
  • Both lock tests fail when I revert _core.py. The background-task test also passes on the base branch, because feat: Run reactive effects #2508 made this behavior work. It protects the pattern that the docs now recommend.
  • A separate test that I did not commit ran the skill's producer example with two sessions. Both sessions got updates. Updates continued after the first session closed and after a failed fetch. The producer did not run in the first session's context.
  • uv run make format check-lint and pyright are clean. pyrefly is clean when it is run with --use-ignore-files=false. (This worktree is in a git-excluded folder, so the make check-types target finds no files there.)
  • The skill validator passes. The full pytest tests/pytest suite passes: 1316 tests. I also ran the new tests on Python 3.10.
  • On the base branch, test_value_set_in_download_handler_updates_outputs_and_effects[async] (from feat: Run reactive effects #2508) fails in most runs of the full test_concurrency.py file. It passes when it runs alone. This PR does not cause it: the base branch failed 9 of 10 runs, and this branch failed 10 of 10.

Refs #698

jat255 added 3 commits October 3, 2026 10:33
Shiny no longer takes the lock, so holding it doesn't protect anything.
`reactive.lock()` now warns and returns an object that never blocks.
Setting a reactive value schedules a flush, so background tasks only need
to call `.set()`; `session.run_once_when_idle()` applies a change once a
session's effects have finished. Docs, the bundled skill and the
CHANGELOG point to these instead of `lock()` + `flush()`.
`_NoOpLock` now subclasses `asyncio.Lock`, so the public signature
and `isinstance` checks keep working. The warning says the function will
be removed, and the docs say holders no longer exclude each other.

The new background-task test no longer passes `context=` to
`create_task()`, which needs Python 3.11. The skill's producer example
starts its task in a fresh context and survives a failed fetch.
@jat255
jat255 force-pushed the jat255/698-lock-flush-replacement branch from e0ff53b to 56b49e6 Compare October 3, 2026 14:40

This branch has not been deployed

No deployments
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