Problem
Streamed live-runner sessions (trickle / websocket) must keep paying for as long as the session is open, because the orchestrator meters the open session by wall-clock/segments and drops it when the balance runs dry. Today the SDK exposes this as a free function, run_session_payments(session, interval=...), which the caller must drive manually:
payment_task = asyncio.create_task(run_session_payments(session, interval=...))
...
finally:
payment_task.cancel()
await payment_task
reserve_session already mints and attaches the payment_session credential to the returned LiveRunnerSession, but LiveRunnerSession is a frozen dataclass with no lifecycle, so it cannot own the background task. That pushes task creation and (critically) cancellation onto every consumer. Forget the finally and you silently stop paying or leak a task. The echo example (example-apps/echo/client.py) shows the boilerplate this requires.
Prior art (already in our codebase)
Two better-encapsulated patterns already exist and prove the approach:
lv2v.py: Lv2vJob.start_payment_sender() runs a per-output-segment payment sender, auto-started by start_lv2v(start_payments=True) and cancelled by job.aclose(). The consumer cannot forget it.
- daydreamlive/scope
ja/runner (server/livepeer_client.py): LivepeerClient starts an interval _payment_loop right after connect, gated on payment_session is not None, and cancels it on teardown. This is effectively run_session_payments hand-rolled inside a client object.
Proposal
Make reserve_session return a stateful session/client that owns the payment lifecycle, so payments are automatic for on-chain sessions:
async with await reserve_session(app=..., signer_url=signer) as session:
# publish/consume trickle; payments start and stop automatically
- Auto-start the payment loop when
payment_session is not None (on-chain only; no-op offchain).
- Auto-cancel on context exit /
aclose(), alongside stop_runner_session.
- Keep
run_session_payments as the internal primitive.
- This mirrors
start_lv2v(start_payments=True) + job.aclose().
Payments still need a scope anchor (the session must know when the stream ended); the context manager / aclose() provides it. The object cannot infer that media stopped on its own.
Open questions
- One unified
LiveRunnerSession object that subsumes Lv2vJob, or keep lv2v separate and only wrap the general live-runner path? The former removes the parallel implementations; the latter is the minimal change.
- Cadence semantics to standardize on: lv2v pays per output segment (only while media flows);
run_session_payments pays on a fixed interval (pays even when paused-but-open). These differ in over-payment behavior and should be decided before standardizing the interface.
Context
Follow-up from the echo example migration and the run_session_payments work (branch rs/live-runner-session-payments). Related: gateway PR #25 (SSE streaming).
Problem
Streamed live-runner sessions (trickle / websocket) must keep paying for as long as the session is open, because the orchestrator meters the open session by wall-clock/segments and drops it when the balance runs dry. Today the SDK exposes this as a free function,
run_session_payments(session, interval=...), which the caller must drive manually:reserve_sessionalready mints and attaches thepayment_sessioncredential to the returnedLiveRunnerSession, butLiveRunnerSessionis a frozen dataclass with no lifecycle, so it cannot own the background task. That pushes task creation and (critically) cancellation onto every consumer. Forget thefinallyand you silently stop paying or leak a task. The echo example (example-apps/echo/client.py) shows the boilerplate this requires.Prior art (already in our codebase)
Two better-encapsulated patterns already exist and prove the approach:
lv2v.py:Lv2vJob.start_payment_sender()runs a per-output-segment payment sender, auto-started bystart_lv2v(start_payments=True)and cancelled byjob.aclose(). The consumer cannot forget it.ja/runner(server/livepeer_client.py):LivepeerClientstarts an interval_payment_loopright after connect, gated onpayment_session is not None, and cancels it on teardown. This is effectivelyrun_session_paymentshand-rolled inside a client object.Proposal
Make
reserve_sessionreturn a stateful session/client that owns the payment lifecycle, so payments are automatic for on-chain sessions:payment_session is not None(on-chain only; no-op offchain).aclose(), alongsidestop_runner_session.run_session_paymentsas the internal primitive.start_lv2v(start_payments=True)+job.aclose().Payments still need a scope anchor (the session must know when the stream ended); the context manager /
aclose()provides it. The object cannot infer that media stopped on its own.Open questions
LiveRunnerSessionobject that subsumesLv2vJob, or keep lv2v separate and only wrap the general live-runner path? The former removes the parallel implementations; the latter is the minimal change.run_session_paymentspays on a fixed interval (pays even when paused-but-open). These differ in over-payment behavior and should be decided before standardizing the interface.Context
Follow-up from the echo example migration and the
run_session_paymentswork (branchrs/live-runner-session-payments). Related: gateway PR #25 (SSE streaming).