Repository navigation
Conversation
Fixes found while reviewing #2549, #2545, #2546, #2520, #2547, and #2548: - `reactive.flush()` could return before a session's outputs were sent. `run_until_idle()` ignored a round requested via `call_soon` that had not started a task yet (#2549). - Errors in the output flush task (input updates, `on_flush`/`on_flushed` callbacks) lost their traceback; only the message was printed (#2549). - `session.destroy(id)` ran destroy callbacks in registration order, so a scope's values were destroyed before its effects were cancelled. An async `on_destroy` callback that yields let a running effect read a destroyed value, which closed the whole session (#2545). - The base `Session.run_once_when_idle()` ran `fn` synchronously, against its docstring; it is now abstract (#2546). - The changelog and docstring now say that `reactive.lock()` no longer provides mutual exclusion and warns once per process (#2520). - With bookmarking on, the bookmark effects turned the session busy before the `session_start` span existed, so the first cycle's `reactive_update` span was a root trace. They are now created inside the span (#2547). - The session-end test for the cycle span passed without the code it covered; it now checks that the span ends before the cancelled effect does (#2547). - Effects queued before their session ended hung forever on the bookmark restore gate, because the destroyed restore effect never opened it. A destroyed effect no longer waits on the gate (#2548).
… test On Windows, `time.time_ns()` is too coarse to separate the span's end time from the cancelled effect's `finally`, so the two timestamps were equal and the test failed. Record whether the session's cycle span had already ended when the `finally` ran instead.
jat255
added this pull request to stack #2550
October 14, 2026 16:13
jat255
reviewed
Oct 14, 2026
…or-check' into HEAD
…or-check' into HEAD
jat255
approved these changes
Oct 15, 2026
…sn't cancel a run's cleanup
…ng at the end of a round
…y a destroyed effect
…n_ended callbacks
…n't visible to run_until_idle()
…f copying its constructor
…error closes the session
… its last session ends
…der_output Its two header lines were missing, so its body sat inside the preceding test (pyright: "server" obscured) and it never ran on its own.
…ations Tests that relied on one sleep being longer than another (an older effect run finishing after a newer one, a stale calc run finishing after the fresh one, a background task starting after its effect's run) now release each step with an asyncio.Event, so the order no longer depends on the clock. Adds settle(), a bounded number of loop passes, for checking that a parked run stays parked.
… waits CI machines are slow, so a test can't assume a sleep of N ms was long enough for something (not) to happen. Each negative assertion now waits for internal state that proves the thing is parked (the update in the session's cycle-start queue, the effect's run task registered, the calc's waiter list, the session's busy count), then checks after a bounded number of loop passes. Start-up settling uses reactive.flush(), which also fails fast when a flush loop never goes idle.
Also raise the wait limit to 10s like test_concurrency's: waits return as soon as their condition holds, so a long limit costs passing tests nothing.
…ead of sleep durations (#2562)
…e/2508-stack-review-fixes
…d of making it abstract An abstract method stops any Session subclass outside shiny (e.g. a downstream test mock) from being instantiated, even if it never calls run_once_when_idle().
…e/2508-stack-review-fixes # Conflicts: # CHANGELOG.md
An effect of a module scope reaches it through the proxy's root session, passing the scope's namespace. Sessions that aren't an AppSession (or a proxy of one) fall back to on_destroy().
…e/2508-stack-review-fixes
…e/2508-stack-review-fixes
…e/2508-stack-review-fixes
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #2525. Fixes the findings from two reviews of the whole stack (#2549, #2545, #2546, #2520, #2547, #2548), so that the fixes can be seen in one place. One commit per finding, so each can be inspected (or squashed down into its PR) on its own.
First review
#2549 (non-waiting flush)
reactive.flush()could return before a session's outputs were sent.run_until_idle()treated "no running task, empty effect queue" as idle, but ignored_round_requested: a round requested viacall_soon(e.g. by an effect's last step, to start its session's output flush) hadn't started a task yet. The idle check now also requires no pending request. Test:test_flush_waits_for_a_round_requested_by_an_effects_last_step.on_flush/on_flushedcallback, or the restore context flush was passed only to_unhandled_error(), which prints the message. The traceback is now printed first (the_start_cycle()print moved there, so an action's error is printed once). Test:test_flush_callback_error_closes_the_session_with_a_traceback.#2545 (cancel effects on destroy)
session.destroy(id)runs destroy callbacks in registration order, and a value is usually declared before the effects that read it. That only worked because nothing yielded between callbacks: an asyncon_destroycallback that awaits let a running effect resume, read the destroyed value, and close the whole session withDestroyedReactiveError.AsyncCallbacks.register()takesfirst=True, effects register their destroy callback with it (AppSession._on_destroy_first(fn, ns=), reached from a module effect through its proxy's root session; not aSessionmethod), so effects are cancelled before any other destroy callback runs. The existing test now has an asyncon_destroycallback and releases the effect before the destroy, which made it fail before the fix.#2546 (
run_once_when_idle)Session.run_once_when_idle()ranfnsynchronously, against its own docstring. It now raisesNotImplementedError(all three subclasses implement it). It isn't abstract, so aSessionsubclass outside shiny, such as a test mock, can still be instantiated.#2520 (deprecate
reactive.lock())asyncio.Lock.v1.9.0wording stays.#2547 (per-session
reactive_updatespan)bookmark._create_effects()ran before thesession_startspan was set; the effects it creates invalidate, which turns the session busy and starts the cycle span with no parent. The effects are now created inside the span, before the server function.test_effect_spans_are_children_of_their_sessions_reactive_updateruns withbookmark_store="url"too.test_session_ending_mid_cycle_ends_its_reactive_updatepassed without the_end_otel_cycle_span()call in_run_session_ended_tasks, because the cancelled effect'sfinallyended the span anyway. It now asserts that the span ended before the cancelled effect'sfinallyran.reactive_updatespan #2547 instead of feat(otel): Give each session's cycle its ownreactive_updatespan #2522.#2548 (bookmark restore gate)
_destroyedand skipped its run, so it never opened the gate, and every lower-priority effect of the session waited on it forever (leaking the tasks and keepingreactive.flush()from returning). A destroyed effect no longer waits on the gate. Test:test_session_ending_before_its_restore_effect_runs_releases_its_effects.Second review
Correctness
Effect_.destroy()interrupted a cancelled run's cleanup (feat(reactive): Cancel an effect's in-progress run when it is destroyed #2545). A session registersdestroyfor both its end and its destroy, and a cancelled task stays in_run_tasksuntil itsfinallycompletes, so the second call cancelled the cleanup mid-await.destroy()is now idempotent. Test:test_destroying_an_effect_twice_does_not_interrupt_its_cleanup.run_once_when_idle()after the session ended grew the queue forever (feat(session): Addsession.run_once_when_idle()#2546). Nothing drains_cycle_start_action_queueonce the session has ended, so a custom timer that kept calling it leaked one closure per call. Calls after the session ends are now dropped right away. Test:test_run_once_when_idle_after_the_session_ends_is_dropped_right_away.finallyreset_round_requestedand requested again, so two callbacks each spawned astart_round(); the second set_rerun_round, and the first ran one more round with nothing in it, firing everyreactive.on_flushed()callback (and the app's output-flush hook) an extra time. The early-return path now clears the request flag, and thefinallyjust requests (a no-op if a request is pending). Test:test_round_requested_during_a_round_runs_exactly_one_more_round.NotifyException(close=True)from a destroyed effect closed the session (feat(reactive): Cancel an effect's in-progress run when it is destroyed #2545), against the rule that errors from a destroyed effect are only logged. Test:test_notify_exception_from_a_destroyed_effects_cleanup_keeps_session_open.exceptinon_flush_cbonly called_unhandled_error(), which prints the message. It prints the traceback first now.reactive_updatespan #2547)._end_otel_cycle_span()always setStatusCode.OK, even when_unhandled_error()closed the session. The cycle span now records the exception and ends withERROR. Test:test_reactive_update_reports_the_error_that_closed_the_session.Docs
reactive_updatespan of its own even if it runs no reactive code. Verified against R:incrementBusyCount()in R's download handler opens the span the same way. Documented in the otel module docstring, the otel skill, and the busy-count comment (feat(otel): Give each session's cycle its ownreactive_updatespan #2547).on_flushruns on every output flush, whether or not it has outputs to send, likeon_flushed(feat: Run reactive effects #2549).on_ended/on_destroycallbacks; documented inEffect_.destroy()and the session-lifecycle skill, and asserted intest_effect_that_closes_its_session_finishes_teardown(feat(reactive): Cancel an effect's in-progress run when it is destroyed #2545).run_once_when_idle()no longer promises "on the next pass of the event loop" (feat(session): Addsession.run_once_when_idle()#2546).reactive.lock()docs say when to keep theawait reactive.flush(): the old form returned after the dependents ran,set()returns before (feat(reactive): Deprecatereactive.lock()#2520).reactive_updatespan no longer covers sending the outputs (feat(otel): Give each session's cycle its ownreactive_updatespan #2547), and cites feat: Run reactive effects #2549/feat(reactive): Cancel an effect's in-progress run when it is destroyed #2545/feat(session): Addsession.run_once_when_idle()#2546 instead of feat: Run reactive effects #2508/feat(reactive): Cancel an effect's in-progress run when it is destroyed #2515/feat(session): Addsession.run_once_when_idle()#2517._on_destroy_first()'s ordering guarantee is scoped to one scope: child scopes are destroyed before their parent (fix: Address review findings across the async concurrency stack #2552).request_round()notes that a cross-thread request isn't visible torun_until_idle()until the loop runs the retry.Tests and cleanups
AsyncCallbacksentries are aNamedTupleinstead of an index-addressed 3-tuple (fix: Address review findings across the async concurrency stack #2552).RestoringClientbuilds onRecordingClient(bookmark_store=) instead of copying its constructor (fix(bookmark): Run async on_restore callbacks before the session's effects #2548).KeyError; the express-session-start test waits for a count of 0 or 1 rather than any count (fix(session): Send each output's progress message before it renders #2525).MockTime.advance_time()yields until no round is requested, running, or queued instead of a fixed ten passes. (reactive.flush()can't be used there: a mocked sleep inside an effect callsadvance_time, which would then wait on that same effect.)test_concurrency.pyandtest_otel_reactive_update.py: about 40 tests that slept and then asserted "nothing happened", or ordered effect runs by sleep durations, now wait on state (_run_tasks,_busy_count,_run_waiters,_cycle_start_action_queue) orasyncio.Events. The remaining sleeps only make bodies async or space requests; no assertion depends on their duration.test_otel_reactive_update.py'sTIMEOUTis 10 s liketest_concurrency.py's.Not changed
mainsends a message after every client message, even an empty one, and the client needs it in some situations.test_async_calc_reader_waits_for_the_latest_run, is converted in test(concurrency): Order the calc-reader test's runs with events instead of sleep durations #2562 (stacked on this PR).Testing
Each new or changed test was run without its fix (fails) and with it (passes); the converted timing tests were run three times under xdist.
pytest tests/pytestpasses (1400 tests), as douv run make format check-lint check-types check-pyright. ThebookmarkandotelPlaywright suites pass on Chromium.