Skip to content

feat(session): Add session.run_once_when_idle() - #2517

Draft
schloerke wants to merge 2 commits into
schloerke/async-session-end-cancelfrom
schloerke/async-run-once-when-idle
Draft

schloerke wants to merge 2 commits into
schloerke/async-session-end-cancelfrom
schloerke/async-run-once-when-idle

Conversation

@schloerke

@schloerke schloerke commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #2515 (which is stacked on #2508). Fixes #2508's TODO: a public API in place of invalidate_later's private session._cycle_start_action().

Why

A session holds input changes and invalidate_later() timers until all of its effects have finished, so an effect paused at an await keeps seeing the same values. Third-party code couldn't do the same thing without calling a private method. On #2182, Joe asked that someone be able to write their own invalidate_later without touching private members.

Change

  • New session.run_once_when_idle(fn). It runs fn once, at the start of the session's next cycle: once all of the session's effects have finished and its outputs have been sent. If the session is already idle, it runs on the next pass of the event loop, never synchronously.
  • One cycle per call: functions queued while the session is busy run in order, and each starts a cycle of its own, so the effects one triggers finish before the next runs.
  • Session context: fn runs with its session as the current session; from a module, that's the module's session.
  • When it doesn't run, or fails: it is dropped if the session ends first. An error it raises closes the session, as an error in an effect does.
  • fn must be synchronous, so the cycle it starts can't be interrupted partway. An async function raises TypeError. Its return value is ignored, so lambda: value.set(x) type-checks.
  • Internal callers switched over: invalidate_later() (and so poll() and file_reader()) and client input updates now use it.
  • Express: the stub session used while rendering the UI ignores the call, as it does on_flush().

Naming: R calls this cycleStartAction(). It isn't in R's documented session API, and its only users are Shiny itself and MockShinySession. We chose a verb-first name that says the function runs once, and when.

Docs

  • Docstring with an example, registered in docs/_quartodoc-core.yml.
  • CHANGELOG entry under New features.
  • The bundled skill's reactivity reference covers when to use it and has a quick-reference row.

Verification

  • 7 new tests in tests/pytest/test_concurrency.py:
    • it runs on the next loop pass, once, with the session current;
    • it waits for running effects, which see a stable value;
    • queued functions run one cycle each, in order;
    • from a module, it runs in the module session;
    • async functions are rejected, at both the root and module levels;
    • it's dropped if the session ends first;
    • an error closes the session.
  • pytest tests/pytest: 1315 passed. pyright, pyrefly, flake8, black, the skill validator and the packaging tests are clean.

@schloerke
schloerke added this pull request to stack #2516 October 1, 2026 20:30
schloerke added a commit that referenced this pull request Oct 1, 2026
@schloerke schloerke mentioned this pull request Oct 1, 2026
13 of 15 tasks
schloerke added a commit that referenced this pull request Oct 1, 2026
@schloerke
schloerke force-pushed the schloerke/async-run-once-when-idle branch from 812a05c to 4e43bd7 Compare October 1, 2026 20:35
schloerke added a commit that referenced this pull request Oct 2, 2026
@schloerke
schloerke force-pushed the schloerke/async-run-once-when-idle branch from 4e43bd7 to 48d0639 Compare October 2, 2026 19:11
Make the session's cycle-start queue public, so third-party code can change
reactive state the way input updates and `invalidate_later()` do: once all of
the session's effects have finished and its outputs have been sent, so effects
still running keep seeing stable values. `invalidate_later()` now uses it
instead of the private `_cycle_start_action()`.

`fn` runs with its session (or module session) as the current session, once
per call, in order, each starting a cycle of its own. It is dropped if the
session ends first, and an error it raises closes the session. Async
functions are rejected with a TypeError, since actions must not yield
partway through.
@jat255
jat255 force-pushed the schloerke/async-run-once-when-idle branch from f8687c0 to c6cf363 Compare October 3, 2026 14:40
@schloerke

Copy link
Copy Markdown
Collaborator Author

After feedback w/ team IRL, we should adopt the on_*(fn, once=True) approach that on_flush(fn, once=True) and on_flushed(fn, once=True) have already.

Drive by ideas:

  • on_cycle_start(fn)
  • on_idle(fn)

I like on_cycle_start(fn, once:bool =True) given R precedence, on_* prefix matching, and clear description of when the fn will run.

@gadenbuie

Copy link
Copy Markdown
Collaborator

I like on_cycle_start(fn, once:bool =True) given R precedence

I like this too, but technically, isn't it running on the cycle end rather than the cycle start? I got there by wondering if on_flush_cycle_start() would be a better name, and I thought that no, that would be a callback that's run when the flush cycle is starting.

But IIUC, in this case idle means when the flush cycle ends, i.e. when the cycle is complete. I think the key naming principle of on_{event_name} is that event_name is an event that happens outside of the control of the callback. In that framing, on_cycle_start would mean when the cycle starts for any reason, but (again IIUC) this callback is intended to fire when the server enters the idle state and it may or may not kick off another cycle, depending on what the code in the callback does.

So maybe on_cycle_end or on_flush_cycle_end? If that's not correct, it could help to explain how this callback is different from on_flushed.

OTOH, I could have also been confused by the original run_once_when_idle() and perhaps on_cycle_start() really does mean at the start of a reactive flush cycle.

@schloerke

Copy link
Copy Markdown
Collaborator Author

How it differs from on_flushed(fn, once=True)

  on_flushed(fn, once=True) run_once_when_idle(fn)
Runs after the next outputs message, together with every other on_flushed callback after all on_flushed callbacks, and only if the session is still idle
Several registered all run in the same pass one per cycle, each after the previous one’s effects finish and its outputs are sent
Session already idle with nothing to send waits for the next flush that sends something runs on the next loop pass
Shares order with client input updates and timers no yes, the same queue
What its changes belong to the cycle that’s just ending a new cycle of its own

@schloerke

Copy link
Copy Markdown
Collaborator Author

From Agent:

A caution about on_cycle_start(once=False): with this queue, a callback that runs again at every cycle boundary and changes reactive state would start a new cycle each time, so the session would never stay idle. That’s fine for callbacks that only read or log, but a recurring callback that sets values would keep the app busy forever. If we adopt once: bool = True, the docstring should warn about that. Alternatively, once=False could re-queue only after a cycle that started any effects.

@gadenbuie

gadenbuie commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Thinking about this more from first principles, I've talked myself out of the on_* family entirely — including my own on_cycle_end suggestion. The reason: this mechanism is a scheduler, not an observer, and on_* promises an observer.

What the harness actually does around fn:

  • It defers — never runs fn synchronously, waits for a pass of the event loop when the session is idle.
  • It initiates the cycle itself — fn isn't notified of a cycle start, it is the cycle start. Even a no-op fn starts a degenerate cycle: on_flush callbacks run, an (empty) outputs message is sent, on_flushed runs.
  • It follows through — requests the flush that sends the new cycle's outputs before the next queued function runs.

Against the specific on_* candidates:

  • on_cycle_start breaks the on_* convention in the opposite direction I first worried about. A handler named on_cycle_start(fn, once=False) reads as "call me whenever a cycle starts," but this callback only fires for cycles it initiates. It won't fire when a client input update starts a cycle — even though inputs now share the same queue. It looks like an observation point on the cycle lifecycle, and it isn't one.
  • on_cycle_end is falsifiable: cycle end doesn't reliably precede firing. An async on_flush callback can yield long enough for an effect to start mid-flush; the cycles coalesce and the queued function stays queued until the combined cycle ends. The true predicate is idle — but on_idle still carries the observer framing, so it inherits the same objection.
  • (I'd still take run_once_when_idle over any on_* name — it's honest about being verb-first. But I think we can do better.)

That leaves verb-first names. Two candidates:

1. session.call_when_idle(fn)

Strong prior art in asyncio's scheduling family: call_soon / call_later / call_at. This is the missing session-scoped member — call_soon = next tick, call_later = after a timer, call_when_idle = when the session settles. Python users already know the family contract, and it's exactly our contract: deferred, never synchronous, one run per call. (Note: call_at_idle() could work just as well.)

2. session.queue_cycle_start_action(fn)

Keeps the R cycleStartAction lineage, but adds the verb that disambiguates it from an observer reading. And I think "queue" has a slight edge over "call" for accuracy: it conveys that these run one at a time — each function's cycle (effects + outputs) completes before the next runs, FIFO with input updates and timers. call_when_idle sounds like all registered callbacks would be called together the way on_flushed callbacks are, and they aren't.

Note that both drop "once": one call = one run, so the word is redundant (setTimeout doesn't say it either), and there's no recurring mode to distinguish from — which is just as well, given the busy-forever hazard flagged upthread for once=False.

After all of this, I lean toward queue_cycle_start_action() because it feels the most transparently honest and keeps our naming consistent, but I could just as easily be talked into call_when_idle().

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.

2 participants