Skip to content

Live Runner + Scope support - #20

Open
j0sh wants to merge 65 commits into
mainfrom
ja/live-runner
Open

Live Runner + Scope support#20
j0sh wants to merge 65 commits into
mainfrom
ja/live-runner

Conversation

@j0sh

@j0sh j0sh commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • New Features
    • Added runnable demos for video echo, websocket ping/pong, and text/SSE streaming.
    • Added runner discovery, selection, session reservation, streaming calls, and improved payment handling.
    • Added media frame, packet, and byte callbacks with lifecycle management.
    • Expanded public gateway exports and error reporting.
  • Bug Fixes
    • Improved media segment rollover, shutdown handling, signer validation, and HTTP error handling.
  • Documentation
    • Added setup and usage instructions for all new examples and test workflows.
  • Tests
    • Added broad coverage for discovery, runners, streaming, media processing, payments, and shutdown behavior.

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds gateway HTTP, discovery, runner selection, payment, media callback, Scope, and media rollover functionality. It adds echo, ping-pong, and text runner examples, plus tests, coverage settings, CI execution, and test documentation.

Changes

Gateway core

Layer / File(s) Summary
HTTP, discovery, errors, and signer handling
src/livepeer_gateway/http.py, src/livepeer_gateway/discovery.py, src/livepeer_gateway/errors.py, src/livepeer_gateway/remote_signer.py, src/livepeer_gateway/__init__.py, src/livepeer_gateway/orchestrator.py
Adds shared HTTP helpers, discovery APIs, typed errors, async signer payment support, compatibility aliases, and public exports.
Runner lifecycle, payment, and selection
src/livepeer_gateway/live_runner.py, src/livepeer_gateway/selection.py
Adds runner registration, trickle and proxy operations, streaming calls, payment retries, GPU detection, runner selection, and session reservation.
Scope and media callback integration
src/livepeer_gateway/scope.py, src/livepeer_gateway/channel_reader.py, src/livepeer_gateway/media_output.py, src/livepeer_gateway/lv2v.py
Moves Scope startup to asynchronous runner selection and adds callback consumers with lifecycle and error handling.
Media publish rollover
src/livepeer_gateway/media_publish.py
Separates segment draining from EOF closure and updates idle rollover and write-failure handling.

Runner examples

Layer / File(s) Summary
Echo video runner flow
examples/echo/*
Adds a video echo runner and client with frame transformations, runtime updates, trickle channels, and streamed output.
Ping-pong websocket runner
examples/ping-pong/*
Adds runner registration, websocket ping validation, timing responses, client discovery, and usage documentation.
Text streaming runner
examples/text/*
Adds text, SSE, and health endpoints with static runner configuration, story content, and run instructions.

Validation and tooling

Layer / File(s) Summary
Gateway behavior tests
tests/test_channel_reader.py, tests/test_discovery.py, tests/test_live_payment_session.py, tests/test_live_runner.py, tests/test_selection.py, tests/test_start_scope.py, tests/test_token.py, tests/test_websocket_example.py, tests/test_control_keepalive.py
Adds coverage for gateway APIs, runner lifecycle, payment flows, selection, Scope startup, token parsing, keepalive behavior, and the websocket example.
Media pipeline and shutdown tests
tests/test_media_publish.py, tests/test_stats_pull.py, tests/test_decoder_queue_metrics.py, tests/test_trickle_shutdown_races.py
Adds coverage for media rollover, statistics, callbacks, decoder metrics, queue behavior, and shutdown races.
CI and test configuration
pyproject.toml, .github/workflows/tests.yml, README
Raises the Python requirement to 3.12, adds test and coverage configuration, runs tests in CI, and documents test commands.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant EchoClient
  participant RunnerSelector
  participant EchoRunner
  participant MediaPublish
  EchoClient->>RunnerSelector: reserve_session
  RunnerSelector->>EchoRunner: POST /echo
  EchoRunner->>MediaPublish: publish transformed frames
  EchoClient->>EchoRunner: POST /update blur mode
Loading
sequenceDiagram
  participant PingClient
  participant RunnerSelector
  participant PingRunner
  PingClient->>RunnerSelector: discover runner
  PingClient->>PingRunner: websocket ping timestamp
  PingRunner-->>PingClient: pong and delta_ms
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary changes: adding Live Runner and Scope support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ja/live-runner

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread src/livepeer_gateway/channel_reader.py Fixed
Comment thread src/livepeer_gateway/live_runner.py Fixed
Comment thread src/livepeer_gateway/media_output.py Fixed
Comment thread src/livepeer_gateway/channel_reader.py Fixed
Comment thread src/livepeer_gateway/live_runner.py Fixed
Comment thread src/livepeer_gateway/live_runner.py Fixed
Comment thread src/livepeer_gateway/media_output.py Fixed
@j0sh
j0sh force-pushed the ja/live-runner branch from d0d3453 to 2bc02a7 Compare May 27, 2026 16:29
Comment thread src/livepeer_gateway/live_runner.py Fixed
Comment thread src/livepeer_gateway/live_runner.py Fixed
Comment thread src/livepeer_gateway/live_runner.py Fixed
Comment thread src/livepeer_gateway/live_runner.py Outdated
runner_url: str,
app: str,
price_per_unit: int = 0,
pixels_per_unit: int = 1,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@j0sh can we use something like unit_scale since this runner also allows different pricing schemes. Also see my pull request in go-liveper livepeer/go-livepeer#3942.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll look into that idea (and the general move from away from pixels to purely timing) but don't want to block this merge for that

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@j0sh yea no blocker from my side I see this as a nice follow up improvement for consistency. I am tracking this in #27.

Comment thread src/livepeer_gateway/live_runner.py Outdated
secret: str,
runner_url: str,
app: str,
price_per_unit: int = 0,

@rickstaa rickstaa Jun 29, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@j0sh How do you intend pricing to work for dynamic runners? Right now the app self-asserts price_info via register_runner(price_per_unit=…) and go-livepeer trusts it (only > 0 is checked in normalizeHeartbeat). That's fine for operator-deployed/trusted containers — the operator sets the price via env and the app forwards it — but an untrusted image could ignore that and under-report its price. Static sidesteps this (operator sets price_info in runners.json); only dynamic trusts the app.

You can see how I'm currently using this in the hello_world example, which feels a bit strange since it relies on the app to create the argument and forward it.

Two thoughts:

  • If the orchestrator is still meant to set the price in dynamic mode (to make gaming harder), the SDK could auto-read it from env (e.g. PRICE_PER_UNIT) instead of the app passing it to register_runner, keeping pricing a deployment/operator concern, out of app code.
  • I think we'll eventually move to GPU-based pricing (orchestrator states a price per GPU type, workloads auto-run at that rate), so go-livepeer would override the reported price at registration anyway.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct, the runner itself reports the price because the orchestrator is intended to control the runner.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This way runner provisioning (including pricing) can be configured separately from go-livepeer without having to introduce a mutual dependency on one other. Setting the price on go-livepeer itself introduces a tension with the orchestrator needing to be configured separately with details of the runner / workload, hardware, etc, as opposed to runners being able to simply connect and go.

If you want to keep the configuration / pricing within go-livepeer then static configuration is the way to go.

Comment thread src/livepeer_gateway/channel_reader.py Fixed
Comment thread src/livepeer_gateway/live_runner.py Fixed
Comment thread src/livepeer_gateway/live_runner.py Fixed
Comment thread src/livepeer_gateway/media_output.py Fixed
Comment thread src/livepeer_gateway/channel_reader.py Fixed
Comment thread src/livepeer_gateway/live_runner.py Fixed
Comment thread src/livepeer_gateway/media_output.py Fixed
seanhanca added a commit that referenced this pull request Jul 18, 2026
…ge into ja/live-runner (#46)

## Why
The production SDK (`sdk-service:byoc-dual-path-1bf13cd`) carries a
**load-bearing byoc-payment fix that exists in no branch** — only in the
running container. That's an operational liability and it blocks a
unified gateway. This ports it onto `ja/live-runner` (the Live Runner +
Scope branch, PR #20 → main) so the consolidated gateway keeps BYOC
payment working while gaining LR.

## What
- `_payment_type_for_signer(signer_url)` — **legacy Daydream signer**
(`signer.daydream.live`) → `type:"lv2v"` + string `capability`; **modern
signers** (pymthouse DMZ, …) → `type:"byoc"` + BYOC capabilities
protobuf.
- `_create_byoc_payment` — assemble the payment payload + orch-discovery
capabilities per the resolved type.
- `capabilities.py` — `CapabilityId.BYOC` +
`byoc_capabilities_from_app()`.

Two files, +52/−9. Byte-identical to what runs in prod today.

## Relationship to #41
PR #41 sends `type:"byoc"` **unconditionally** and depends on an
undeployed go-livepeer signer+orch change. Against
`signer.daydream.live` (which only accepts `lv2v` today) that reproduces
the **2026-07-13 “invalid job type” outage**. This PR is the
**superset**: per-signer switching keeps the legacy signer working *and*
enables modern signers. Recommend closing #41 in favor of this.

## Follow-on
The same per-signer type logic generalizes to the **live-runner**
payment path, which will let us drop the `lr-gateway` `lv2v` workaround
once this lands.

## Test
- [ ] `python -m py_compile` (passes locally)
- [ ] `submit_byoc_job` against `signer.daydream.live` → `type:lv2v` →
200 (no regression)
- [ ] `submit_byoc_job` against a modern signer → `type:byoc` + caps
proto → 200

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@j0sh
j0sh marked this pull request as ready for review July 27, 2026 18:50

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

🧹 Nitpick comments (13)
src/livepeer_gateway/lv2v.py (1)

124-145: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

on_bytes is not exposed here.

MediaOutput accepts on_bytes/on_frame/on_packet, but this helper forwards only the latter two, so the byte-stream callback is unreachable through LiveVideoToVideo.media_output(...). Add it for parity unless the omission is deliberate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/livepeer_gateway/lv2v.py` around lines 124 - 145, Expose the on_bytes
callback in LiveVideoToVideo.media_output by adding it to the helper’s
parameters and forwarding it to the MediaOutput constructor alongside on_frame
and on_packet. Preserve all existing callback behavior and defaults.
src/livepeer_gateway/media_output.py (1)

198-254: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

start_callbacks() in __init__ binds tasks to whatever loop is current.

Reasonable given the documented fallback, but note the constructor now has an implicit side effect (task creation) whenever any callback is supplied; MediaOutput(...) built in one loop and used in another will fail late. Worth calling out in the class docstring alongside the existing Attributes: list.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/livepeer_gateway/media_output.py` around lines 198 - 254, Update the
MediaOutput class docstring to document that providing callbacks causes __init__
to invoke start_callbacks() and bind callback tasks to the currently running
event loop. Mention that constructing the instance in one loop and using it in
another is unsupported or may fail, while preserving the existing
no-running-loop fallback behavior.
src/livepeer_gateway/channel_reader.py (1)

213-242: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Both subclass __init__ bodies are identical; consider moving the constructor onto _ChannelReaderCallback.

ChannelReader.__init__ and JSONLReader.__init__ differ only in docstring wording. Defining __init__ on the mixin (and dropping _init_callback) removes the duplication and one indirection.

Also applies to: 337-366

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/livepeer_gateway/channel_reader.py` around lines 213 - 242, Move the
shared constructor implementation from ChannelReader and JSONLReader onto
_ChannelReaderCallback, preserving the existing parameters, defaults, and
callback initialization behavior. Remove the _init_callback indirection and the
duplicated subclass __init__ methods, leaving both readers to inherit the mixin
constructor and retain appropriate documentation for the shared behavior.
src/livepeer_gateway/async_cache.py (1)

19-29: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Cache misses on falsy/None results and no in-flight de-duplication.

Two small gaps worth considering:

  • cached is not None conflates "absent" with "cached None". Use a sentinel so None/falsy results are cached correctly.
  • Concurrent callers awaiting the same key all execute func, so get_signer_info can fire N simultaneous signer requests before the first result lands. Caching the awaitable (or guarding with a per-key lock) makes it a true single-flight cache.
♻️ Sentinel + single-flight sketch
+        _MISSING = object()
+        inflight: dict[Any, asyncio.Future[_T]] = {}
+
         `@wraps`(func)
         async def wrapper(*args: Any, **kwargs: Any) -> _T:
             key = (args, tuple(sorted(kwargs.items())))
-            cached = cache.get(key)
-            if cached is not None:
+            cached = cache.get(key, _MISSING)
+            if cached is not _MISSING:
                 cache.move_to_end(key)
-                return cached
-
-            value = await func(*args, **kwargs)
+                return cast(_T, cached)
+
+            existing = inflight.get(key)
+            if existing is not None:
+                return await asyncio.shield(existing)
+            fut: asyncio.Future[_T] = asyncio.get_running_loop().create_future()
+            inflight[key] = fut
+            try:
+                value = await func(*args, **kwargs)
+            except BaseException as e:
+                fut.set_exception(e)
+                raise
+            finally:
+                inflight.pop(key, None)
+            if not fut.done():
+                fut.set_result(value)
             cache[key] = value
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/livepeer_gateway/async_cache.py` around lines 19 - 29, Update the async
cache wrapper around cache.get and func(*args, **kwargs) to use a unique
sentinel for detecting absent keys, preserving cached None and other falsy
results. Add per-key in-flight de-duplication so concurrent misses await the
same computation instead of invoking func repeatedly, while retaining the
existing LRU insertion and eviction behavior.
src/livepeer_gateway/live_runner.py (2)

512-513: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Blocking GPU detection runs on the event loop.

detect_process_gpu() shells out to nvidia-smi twice (2s timeout each) and initializes NVML synchronously inside an async def. That can stall the loop for several seconds during startup. Offloading keeps register_runner cooperative.

♻️ Proposed change
     if gpu is None and auto_detect_gpu:
-        gpu = detect_process_gpu()
+        gpu = await asyncio.to_thread(detect_process_gpu)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/livepeer_gateway/live_runner.py` around lines 512 - 513, Update the GPU
auto-detection branch in register_runner to run detect_process_gpu in a worker
thread or equivalent executor instead of directly on the event loop. Preserve
the existing condition and assign the detection result to gpu after awaiting the
offloaded call.

1145-1154: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Static-analysis "command injection" hits on nvidia-smi are false positives.

Both subprocess.check_output calls use a fixed argv list with no interpolated input, so CWE-78 does not apply. The only substantive part of the hint is S607 (partial executable path); since shutil.which("nvidia-smi") is already resolved at line 1126, passing that resolved path through would silence it and avoid PATH surprises.

Also applies to: 1167-1176

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/livepeer_gateway/live_runner.py` around lines 1145 - 1154, Update both
subprocess.check_output calls in the live runner to invoke the executable path
already resolved by shutil.which("nvidia-smi"), rather than the literal
"nvidia-smi" string. Keep the fixed argument lists and existing command behavior
unchanged.

Source: Linters/SAST tools

src/livepeer_gateway/remote_signer.py (1)

271-280: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicated, redundant aiohttp.ClientConnectorError handler. ClientConnectorError subclasses ClientError, so in both places the extra arm is unreachable-by-necessity and builds an identical error; the getattr(..., ()) fallback would also silently catch nothing if the attribute ever moved.

  • src/livepeer_gateway/remote_signer.py#L271-L280: drop the getattr(aiohttp, "ClientConnectorError", ()) arm in send_payment and keep only (aiohttp.ClientError, asyncio.TimeoutError).
  • src/livepeer_gateway/live_runner.py#L1054-L1059: drop the same arm in _post_empty and keep only (aiohttp.ClientError, asyncio.TimeoutError).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/livepeer_gateway/remote_signer.py` around lines 271 - 280, Remove the
redundant ClientConnectorError handlers from send_payment in
src/livepeer_gateway/remote_signer.py lines 271-280 and _post_empty in
src/livepeer_gateway/live_runner.py lines 1054-1059; retain only the shared
aiohttp.ClientError and asyncio.TimeoutError handler in both locations,
preserving the existing PaymentError conversion and message.
src/livepeer_gateway/scope.py (1)

82-91: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Tighten the helper signatures.

_start_scope_with_runner has no return annotation (it returns LiveVideoToVideo), and _is_serverless_runner takes object + getattr even though the only caller passes Optional[LiveRunnerInstance]. Typing both concretely lets mypy check the raw access.

♻️ Proposed change
     orch_url: Optional[Sequence[str] | str],
     timeout: float,
-):
+) -> LiveVideoToVideo:
-def _is_serverless_runner(runner: object) -> bool:
-    raw = getattr(runner, "raw", None)
-    version = raw.get("version") if isinstance(raw, dict) else None
+def _is_serverless_runner(runner: Optional[LiveRunnerInstance]) -> bool:
+    version = runner.raw.get("version") if runner is not None else None
     return isinstance(version, str) and version.startswith("serverless")

Also applies to: 140-143

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/livepeer_gateway/scope.py` around lines 82 - 91, Update
_start_scope_with_runner to annotate its return type as LiveVideoToVideo, and
tighten _is_serverless_runner to accept Optional[LiveRunnerInstance] instead of
object. Replace the generic getattr-based raw access with typed access so mypy
validates the caller’s LiveRunnerInstance handling.
src/livepeer_gateway/selection.py (1)

295-301: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Reserved runner sessions are never released when post-selection handling fails. call_runner may have already reserved (and paid for) capacity by the time the caller validates or post-processes the response; both sites abandon that reservation on failure, leaving the runner holding capacity until its own timeout.

  • src/livepeer_gateway/selection.py#L295-L301: before raising on missing session_id/app_url, issue a best-effort stop_runner_session for the reserved session.
  • src/livepeer_gateway/scope.py#L103-L137: in the except Exception arm, best-effort release the reserved session on result before appending the RunnerRejection and advancing to the next candidate — otherwise each failed candidate leaks one reservation.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/livepeer_gateway/selection.py` around lines 295 - 301, Release reserved
runner sessions when post-selection handling fails: in
src/livepeer_gateway/selection.py lines 295-301, best-effort call
stop_runner_session for the reserved session before raising for missing
session_id or app_url; in src/livepeer_gateway/scope.py lines 103-137, update
the except Exception path to best-effort release the session from result before
recording RunnerRejection and advancing to the next candidate.
src/livepeer_gateway/http.py (2)

282-282: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unnecessary getattr fallback for aiohttp.ClientConnectorError.

aiohttp.ClientConnectorError has existed since long before the pinned aiohttp 3.9.0; getattr(aiohttp, "ClientConnectorError", ()) adds indirection without a real compatibility benefit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/livepeer_gateway/http.py` at line 282, Update the exception handler
around the aiohttp request flow to directly reference
aiohttp.ClientConnectorError instead of using getattr with an empty-tuple
fallback. Preserve the existing handling and exception alias.

142-163: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Shared transport functions don't enforce http/https scheme validation.

_parse_http_url (defined later in this same file) restricts URLs to http/https, but request_json_sync and request_json accept raw url strings and pass them straight to Request(...)/session.request(...) without going through it. Current callers happen to validate upstream, but centralizing the check here would close off file:///other-scheme risks (flagged by static analysis as S310/urlopen-unsanitized-data) for any future caller that forgets to validate.

🛡️ Proposed fix
 def request_json_sync(
     url: str,
     *,
     method: Optional[str] = None,
     payload: Optional[dict[str, Any]] = None,
     headers: Optional[dict[str, str]] = None,
     timeout: float = 5.0,
 ) -> Any:
+    url = _parse_http_url(url).geturl()
     resolved_method, req_headers, body = _json_request_parts(

(apply the equivalent change to request_json)

Also applies to: 243-269

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/livepeer_gateway/http.py` around lines 142 - 163, Validate the URL
through the existing _parse_http_url helper at the start of both
request_json_sync and request_json before constructing Request or calling
session.request. Use the validated result for the request while preserving the
existing request method, headers, payload, timeout, and error behavior.
src/livepeer_gateway/orchestrator.py (1)

23-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Sync/async naming collision between orchestrator.py and http.py.

orchestrator.post_json/get_json/request_json are synchronous (aliases of the _sync helpers), while http.py defines coroutines with the exact same names. Importing the wrong module in async code (e.g. from .orchestrator import post_json instead of .http) would silently block the event loop instead of raising an obvious error. The existing comment documents intent but doesn't prevent the mix-up.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/livepeer_gateway/orchestrator.py` around lines 23 - 26, Remove or rename
the compatibility aliases request_json, post_json, and get_json in
orchestrator.py so synchronous helpers cannot collide with the asynchronous
functions in http.py; update any callers to use the explicit *_sync names while
preserving synchronous behavior.
src/livepeer_gateway/errors.py (1)

42-55: 📐 Maintainability & Code Quality | 🔵 Trivial

Inconsistent __str__ formatting across rejection-based errors.

NoRunnerAvailableError now formats rejections into its str() output, but the existing sibling error at lines 34-39 (constructed the same way with OrchestratorRejection rejections) does not override __str__ similarly. Operators debugging orchestrator-selection failures get less detail than runner-selection failures despite an identical mechanism.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/livepeer_gateway/errors.py` around lines 42 - 55, The sibling
rejection-based error for orchestrator selection should format its stored
OrchestratorRejection entries in __str__, matching NoRunnerAvailableError.
Update that error class to return the base message when there are no rejections
and otherwise append each rejection’s URL and reason in the same format.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/echo/client.py`:
- Around line 143-152: Update the MediaOutput context flow around _publish_video
so it awaits output stream completion or all pending _write_chunk callback tasks
before exiting the context. Keep the existing publish-complete log, and ensure
MediaOutput is not closed until transformed frames have been fully consumed.
- Around line 85-91: Update both post_json calls for the initial /echo request
and subsequent /update blur requests in the client flow to pass
session.session_id through the supported request-header argument as
Livepeer-Session-Id, ensuring both endpoints receive the reserved session ID.

In `@examples/echo/README.md`:
- Around line 18-20: Update the example command in the README to pass the
configured non-TLS orchestrator URL, changing the runner’s --orchestrator value
from https://localhost:8935 to http://localhost:8935. Keep the existing command
and other arguments unchanged.

In `@examples/echo/runner.py`:
- Around line 191-201: Retain the result of register_runner in _on_startup by
storing registration on the app, then update _on_cleanup to await that
registration’s close() after the pipeline is closed. Ensure cleanup uses the
stored registration so its heartbeat stops and the runner is unregistered.
- Around line 132-168: Serialize singleton session lifecycle access with one
app-scoped asyncio.Lock: use it to guard _handle_echo across the existing state
check and pipeline creation/assignment, and also guard _handle_update,
_close_pipeline, and cleanup reads or writes of state. Ensure concurrent
requests cannot create duplicate MediaOutput/MediaPublish pipelines and that
cleanup safely coordinates with session access.

In `@examples/ping-pong/client.py`:
- Around line 29-33: Update _select_runner to invoke cursor.candidates() before
iteration, so the loop consumes the returned candidate collection rather than
the bound method. Preserve the existing URL return and LivepeerGatewayError
behavior.

In `@examples/text/go-livepeer.conf`:
- Around line 4-5: Update the httpAddr and serviceAddr configuration values to
use host:port format without the http:// scheme, such as localhost:8935, while
preserving the existing port.

In `@examples/text/README.md`:
- Line 3: Correct the platform name from “Livpeeer” to “Livepeer” in the README
description, and add an appropriate shell language identifier to the fenced
command block on line 37 without changing its command content.

In `@src/livepeer_gateway/channel_reader.py`:
- Around line 114-121: Update wait_callback() to exclude asyncio.TimeoutError
from _record_callback_error while preserving propagation of the timeout and
recording other callback exceptions. Keep close() behavior unchanged so
graceful-wait timeouts do not become recorded errors that are later re-raised.

In `@src/livepeer_gateway/http.py`:
- Around line 142-169: Make TLS certificate and hostname verification explicit
and configurable in both request_json_sync and request_json instead of
unconditionally disabling it. Add or reuse a clear verification option, pass it
through the sync SSL context and the async aiohttp.TCPConnector configuration,
and preserve secure verification as the default while allowing self-signed
certificates only when explicitly enabled.

In `@src/livepeer_gateway/live_runner.py`:
- Around line 1043-1048: Update _post_empty to preserve TLS certificate
verification by default: remove the unconditional
aiohttp.TCPConnector(ssl=False) behavior and either use the shared HTTP helper
or add an explicit verify_tls option defaulting to True, only disabling
verification when callers opt in. Ensure calls transmitting Authorization or
Livepeer-Session-Token remain verified.

In `@src/livepeer_gateway/media_output.py`:
- Around line 256-272: The wait_callback helpers must use non-destructive
bounded waits. In src/livepeer_gateway/media_output.py lines 256-272, update
wait_callbacks to use asyncio.wait(callback_tasks, timeout=timeout), collect
results only from completed tasks, and preserve callback error propagation; in
src/livepeer_gateway/channel_reader.py lines 158-184, update wait_callback to
wait on the task without cancelling it when the timeout expires, and document
the intentional asyncio.TimeoutError no-op so close() can cancel it afterward.
- Around line 549-567: Update MediaOutput.close around wait_callbacks so
callback-consumer errors are collected rather than allowed to abort cleanup;
handle the failure from wait_callbacks alongside timeout handling, then continue
cancelling remaining callback tasks, closing all segments, and closing self._sub
before propagating the collected callback error.

In `@src/livepeer_gateway/remote_signer.py`:
- Around line 255-258: Update the headers construction in _payment_request so
Livepeer-Segment always receives a string when payment.seg_creds is None,
matching the existing PaymentSession.send_payment behavior by using an
empty-string fallback.

In `@src/livepeer_gateway/selection.py`:
- Around line 242-256: Update the selection logic around
discover_orchestrator_runners and discover_runners to normalize orchestrators
through the existing orchestrator_discovery_urls helper, treating None, empty
collections, and whitespace-only values as absent. Only use the orchestrator
path when normalization yields URLs; otherwise fall back to discover_runners
with the configured discovery_url and signer_url.

---

Nitpick comments:
In `@src/livepeer_gateway/async_cache.py`:
- Around line 19-29: Update the async cache wrapper around cache.get and
func(*args, **kwargs) to use a unique sentinel for detecting absent keys,
preserving cached None and other falsy results. Add per-key in-flight
de-duplication so concurrent misses await the same computation instead of
invoking func repeatedly, while retaining the existing LRU insertion and
eviction behavior.

In `@src/livepeer_gateway/channel_reader.py`:
- Around line 213-242: Move the shared constructor implementation from
ChannelReader and JSONLReader onto _ChannelReaderCallback, preserving the
existing parameters, defaults, and callback initialization behavior. Remove the
_init_callback indirection and the duplicated subclass __init__ methods, leaving
both readers to inherit the mixin constructor and retain appropriate
documentation for the shared behavior.

In `@src/livepeer_gateway/errors.py`:
- Around line 42-55: The sibling rejection-based error for orchestrator
selection should format its stored OrchestratorRejection entries in __str__,
matching NoRunnerAvailableError. Update that error class to return the base
message when there are no rejections and otherwise append each rejection’s URL
and reason in the same format.

In `@src/livepeer_gateway/http.py`:
- Line 282: Update the exception handler around the aiohttp request flow to
directly reference aiohttp.ClientConnectorError instead of using getattr with an
empty-tuple fallback. Preserve the existing handling and exception alias.
- Around line 142-163: Validate the URL through the existing _parse_http_url
helper at the start of both request_json_sync and request_json before
constructing Request or calling session.request. Use the validated result for
the request while preserving the existing request method, headers, payload,
timeout, and error behavior.

In `@src/livepeer_gateway/live_runner.py`:
- Around line 512-513: Update the GPU auto-detection branch in register_runner
to run detect_process_gpu in a worker thread or equivalent executor instead of
directly on the event loop. Preserve the existing condition and assign the
detection result to gpu after awaiting the offloaded call.
- Around line 1145-1154: Update both subprocess.check_output calls in the live
runner to invoke the executable path already resolved by
shutil.which("nvidia-smi"), rather than the literal "nvidia-smi" string. Keep
the fixed argument lists and existing command behavior unchanged.

In `@src/livepeer_gateway/lv2v.py`:
- Around line 124-145: Expose the on_bytes callback in
LiveVideoToVideo.media_output by adding it to the helper’s parameters and
forwarding it to the MediaOutput constructor alongside on_frame and on_packet.
Preserve all existing callback behavior and defaults.

In `@src/livepeer_gateway/media_output.py`:
- Around line 198-254: Update the MediaOutput class docstring to document that
providing callbacks causes __init__ to invoke start_callbacks() and bind
callback tasks to the currently running event loop. Mention that constructing
the instance in one loop and using it in another is unsupported or may fail,
while preserving the existing no-running-loop fallback behavior.

In `@src/livepeer_gateway/orchestrator.py`:
- Around line 23-26: Remove or rename the compatibility aliases request_json,
post_json, and get_json in orchestrator.py so synchronous helpers cannot collide
with the asynchronous functions in http.py; update any callers to use the
explicit *_sync names while preserving synchronous behavior.

In `@src/livepeer_gateway/remote_signer.py`:
- Around line 271-280: Remove the redundant ClientConnectorError handlers from
send_payment in src/livepeer_gateway/remote_signer.py lines 271-280 and
_post_empty in src/livepeer_gateway/live_runner.py lines 1054-1059; retain only
the shared aiohttp.ClientError and asyncio.TimeoutError handler in both
locations, preserving the existing PaymentError conversion and message.

In `@src/livepeer_gateway/scope.py`:
- Around line 82-91: Update _start_scope_with_runner to annotate its return type
as LiveVideoToVideo, and tighten _is_serverless_runner to accept
Optional[LiveRunnerInstance] instead of object. Replace the generic
getattr-based raw access with typed access so mypy validates the caller’s
LiveRunnerInstance handling.

In `@src/livepeer_gateway/selection.py`:
- Around line 295-301: Release reserved runner sessions when post-selection
handling fails: in src/livepeer_gateway/selection.py lines 295-301, best-effort
call stop_runner_session for the reserved session before raising for missing
session_id or app_url; in src/livepeer_gateway/scope.py lines 103-137, update
the except Exception path to best-effort release the session from result before
recording RunnerRejection and advancing to the next candidate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 79912215-a131-4a38-9289-5ea391d7d1f1

📥 Commits

Reviewing files that changed from the base of the PR and between cc31b56 and 89ac0f7.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (27)
  • examples/echo/README.md
  • examples/echo/client.py
  • examples/echo/runner.py
  • examples/get_orchestrator_info.py
  • examples/ping-pong/README.md
  • examples/ping-pong/client.py
  • examples/ping-pong/runner.py
  • examples/text/README.md
  • examples/text/go-livepeer.conf
  • examples/text/runner.py
  • examples/text/runners.json
  • examples/text/story.txt
  • pyproject.toml
  • src/livepeer_gateway/__init__.py
  • src/livepeer_gateway/async_cache.py
  • src/livepeer_gateway/channel_reader.py
  • src/livepeer_gateway/discovery.py
  • src/livepeer_gateway/errors.py
  • src/livepeer_gateway/http.py
  • src/livepeer_gateway/live_runner.py
  • src/livepeer_gateway/lv2v.py
  • src/livepeer_gateway/media_output.py
  • src/livepeer_gateway/orch_info.py
  • src/livepeer_gateway/orchestrator.py
  • src/livepeer_gateway/remote_signer.py
  • src/livepeer_gateway/scope.py
  • src/livepeer_gateway/selection.py

Comment thread examples/echo/client.py
Comment thread examples/echo/client.py
Comment on lines +143 to +152
async with MediaOutput(out_url, on_bytes=_write_chunk):
await _publish_video(
input_path,
in_url,
max_frames=max(0, args.max_frames),
app_url=session.app_url,
blur=args.blur,
)
_log("publish complete; waiting for output to drain...")
fh.flush()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Wait for output consumption before closing it.

Line 151 claims to drain output, but the context exits immediately afterward and closes MediaOutput; transformed frames still in transit can be truncated. Await stream completion or its callback tasks after _publish_video() and before leaving the context.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/echo/client.py` around lines 143 - 152, Update the MediaOutput
context flow around _publish_video so it awaits output stream completion or all
pending _write_chunk callback tasks before exiting the context. Keep the
existing publish-complete log, and ensure MediaOutput is not closed until
transformed frames have been fully consumed.

Comment thread examples/echo/README.md
Comment on lines +18 to +20
```sh
uv run examples/echo/runner.py --orchestrator https://localhost:8935 --orchSecret abcdef
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the configured HTTP orchestrator URL.

The command starts go-livepeer without TLS but invokes the runner with https://localhost:8935; registration cannot connect. Change this to http://localhost:8935 unless TLS setup instructions are added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/echo/README.md` around lines 18 - 20, Update the example command in
the README to pass the configured non-TLS orchestrator URL, changing the
runner’s --orchestrator value from https://localhost:8935 to
http://localhost:8935. Keep the existing command and other arguments unchanged.

Comment thread examples/echo/runner.py
Comment on lines +132 to +168
if state is not None:
if state.session_id != session_id:
raise web.HTTPConflict(text="echo runner already has an active session")
return web.json_response(state.to_json())

channels = await create_trickle_channels(
request,
[
{"name": "in", "mime_type": "video/mp2t"},
{"name": "out", "mime_type": "video/mp2t"},
],
)
by_name = {channel["name"]: channel for channel in channels}
if "in" not in by_name or "out" not in by_name:
raise web.HTTPInternalServerError(text="orchestrator did not return in/out channels")

# for production apps, handle errors
mode = _parse_mode(json.loads(await request.read()))
publisher = MediaPublish(by_name["out"].get("internal_url", by_name["out"]["url"]))

async def _on_frame(decoded) -> None:
frame = _transform_frame(decoded, mode)
if frame is not None:
await publisher.write_frame(frame)

output = MediaOutput(by_name["in"].get("internal_url", by_name["in"]["url"]), on_frame=_on_frame)

state = EchoSession(
session_id=session_id,
in_url=by_name["in"]["url"],
out_url=by_name["out"]["url"],
mode=mode,
output=output,
publisher=publisher,
)
for task in output.callback_tasks():
task.add_done_callback(lambda _task: asyncio.create_task(_close_pipeline()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)runner\.py$|echo' || true

echo "== target outline =="
ast-grep outline examples/echo/runner.py --view expanded || true

echo "== target lines 1-240 =="
cat -n examples/echo/runner.py | sed -n '1,240p'

echo "== usages of state/pipeline/close/lock =="
rg -n "state|_close_pipeline|create_trickle_channels|asyncio\.Lock|add_done_callback|EchoSession" examples/echo/runner.py -C 3

Repository: livepeer/livepeer-python-gateway

Length of output: 11994


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== asyncio concurrency interleaving probe =="
python3 - <<'PY'
import asyncio

state = None
creations = []
close_runs = []

async def create():
    global state
    await asyncio.sleep(0.01)  # "suspends after single-session check"
    creation = hash(state) % 1000 if state is not None else 999
    creations.append(creation)
    state = "pipeline"
    print("created", creation, "state=", id(state))

async def close_pipeline():
    global state
    if state is None:
        return
    current = state
    state = None
    close_runs.append(current)
    await asyncio.sleep(0.01)

async def callback():
    asyncio.create_task(close_pipeline())

async def handle():
    global state
    if state is not None:
        return "conflict"
    await create()
    # simulate immediate done callback scheduling
    await callback()
    return "ok"

async def main():
    tasks = [handle(), handle()]
    results = await asyncio.gather(*tasks)
    print("results", results)
    print("creations", creations)
    await asyncio.sleep(0.02)
    print("final state", state, "close runs", close_runs)

asyncio.run(main())
PY

echo "== current handlers acquire any lock? =="
rg -n "lock|asyncio|Lock|lock_obj|_lock" .

Repository: livepeer/livepeer-python-gateway

Length of output: 32303


Serialize singleton session creation.

state has an awaitable check/create gap (_handle_echo awaits at create_trickle_channels/request.read() before assigning state), so concurrent requests with the same session can both create MediaOutput/MediaPublish pipelines and only one is closed. Keep one app-scoped asyncio.Lock and guard _handle_echo, _handle_update, _close_pipeline, and cleanup access.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/echo/runner.py` around lines 132 - 168, Serialize singleton session
lifecycle access with one app-scoped asyncio.Lock: use it to guard _handle_echo
across the existing state check and pipeline creation/assignment, and also guard
_handle_update, _close_pipeline, and cleanup reads or writes of state. Ensure
concurrent requests cannot create duplicate MediaOutput/MediaPublish pipelines
and that cleanup safely coordinates with session access.

Comment thread examples/echo/runner.py
Comment on lines +191 to +201
async def _on_startup(app: web.Application) -> None:
args = _parse_args()
registration = await register_runner(
args.orchestrator,
secret=args.orchSecret,
runner_url=args.runner_url,
app="livepeer-sample/echo",
)
print(
f"runner_id={registration.runner_id} orchestrator={registration.orchestrator_url}"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Close the runner registration during app cleanup.

registration is discarded after startup, so its heartbeat task is never closed and the runner is not unregistered. Store it on app and call await registration.close() from _on_cleanup after closing the pipeline.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/echo/runner.py` around lines 191 - 201, Retain the result of
register_runner in _on_startup by storing registration on the app, then update
_on_cleanup to await that registration’s close() after the pipeline is closed.
Ensure cleanup uses the stored registration so its heartbeat stops and the
runner is unregistered.

Comment thread src/livepeer_gateway/live_runner.py Outdated
Comment on lines +1043 to +1048
async def _post_empty(url: str, headers: dict[str, str], timeout: float) -> None:
try:
client_timeout = aiohttp.ClientTimeout(total=timeout)
connector = aiohttp.TCPConnector(ssl=False)
async with aiohttp.ClientSession(timeout=client_timeout, connector=connector) as session:
async with session.post(url, data=b"", headers=headers) as resp:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

TLS verification is disabled for every _post_empty call, including ones carrying secrets.

aiohttp.TCPConnector(ssl=False) turns off certificate verification unconditionally. This path sends the runner heartbeat secret (Authorization header in close(), line 246-250) and the session token (Livepeer-Session-Token in stop_runner_session, line 872-878) to an https:// orchestrator, so a MITM can intercept both. Nothing else in this PR (http.post_json, LivePaymentSession.send_payment) disables verification, so this is an outlier rather than a project-wide convention.

Make it opt-in (e.g. plumb a verify_tls: bool = True / reuse the shared http helpers) rather than the default.

🔒 Proposed change
-async def _post_empty(url: str, headers: dict[str, str], timeout: float) -> None:
+async def _post_empty(
+    url: str,
+    headers: dict[str, str],
+    timeout: float,
+    *,
+    verify_tls: bool = True,
+) -> None:
     try:
         client_timeout = aiohttp.ClientTimeout(total=timeout)
-        connector = aiohttp.TCPConnector(ssl=False)
+        connector = aiohttp.TCPConnector() if verify_tls else aiohttp.TCPConnector(ssl=False)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def _post_empty(url: str, headers: dict[str, str], timeout: float) -> None:
try:
client_timeout = aiohttp.ClientTimeout(total=timeout)
connector = aiohttp.TCPConnector(ssl=False)
async with aiohttp.ClientSession(timeout=client_timeout, connector=connector) as session:
async with session.post(url, data=b"", headers=headers) as resp:
async def _post_empty(
url: str,
headers: dict[str, str],
timeout: float,
*,
verify_tls: bool = True,
) -> None:
try:
client_timeout = aiohttp.ClientTimeout(total=timeout)
connector = aiohttp.TCPConnector() if verify_tls else aiohttp.TCPConnector(ssl=False)
async with aiohttp.ClientSession(timeout=client_timeout, connector=connector) as session:
async with session.post(url, data=b"", headers=headers) as resp:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/livepeer_gateway/live_runner.py` around lines 1043 - 1048, Update
_post_empty to preserve TLS certificate verification by default: remove the
unconditional aiohttp.TCPConnector(ssl=False) behavior and either use the shared
HTTP helper or add an explicit verify_tls option defaulting to True, only
disabling verification when callers opt in. Ensure calls transmitting
Authorization or Livepeer-Session-Token remain verified.

Comment thread src/livepeer_gateway/media_output.py Outdated
Comment thread src/livepeer_gateway/media_output.py Outdated
Comment on lines +255 to +258
headers = {
"Livepeer-Payment": payment.payment,
"Livepeer-Segment": payment.seg_creds,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Livepeer-Segment can be None, which breaks the aiohttp request.

_payment_request explicitly allows segCreds to be absent (line 302-306 only type-checks when non-None), so payment.seg_creds may be None here. aiohttp rejects non-str header values with a TypeError, which is not caught by the ClientError/TimeoutError handlers below and escapes as an untyped error. The sync PaymentSession.send_payment already guards this with p.seg_creds or "" (line 475).

🐛 Proposed fix
         headers = {
             "Livepeer-Payment": payment.payment,
-            "Livepeer-Segment": payment.seg_creds,
+            "Livepeer-Segment": payment.seg_creds or "",
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
headers = {
"Livepeer-Payment": payment.payment,
"Livepeer-Segment": payment.seg_creds,
}
headers = {
"Livepeer-Payment": payment.payment,
"Livepeer-Segment": payment.seg_creds or "",
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/livepeer_gateway/remote_signer.py` around lines 255 - 258, Update the
headers construction in _payment_request so Livepeer-Segment always receives a
string when payment.seg_creds is None, matching the existing
PaymentSession.send_payment behavior by using an empty-string fallback.

Comment thread src/livepeer_gateway/selection.py
Comment thread src/livepeer_gateway/live_runner.py Fixed
Comment thread src/livepeer_gateway/live_runner.py Fixed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/livepeer_gateway/http.py`:
- Around line 326-327: Update open_stream to use aiohttp’s default TLS
certificate and hostname verification instead of constructing its session with
TCPConnector(ssl=False). If insecure TLS is required, thread an explicit opt-in
verification/SSL parameter consistently through open_stream, request_json, and
request_json_sync, preserving verified TLS by default.
- Around line 328-339: Update the request flow around session.request and the
resp.status >= 400 branch to guarantee both response release and session closure
via try/finally, including when reading resp.text() fails. Broaden request
exception handling to clean up and preserve the documented catch-all behavior
for unexpected exceptions, while retaining the existing LivepeerGatewayError
conversion and HTTP JSON error handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8a2a989b-4afd-444f-a33c-5cbeacf9e6cb

📥 Commits

Reviewing files that changed from the base of the PR and between 89ac0f7 and 9f2bc20.

📒 Files selected for processing (3)
  • src/livepeer_gateway/__init__.py
  • src/livepeer_gateway/http.py
  • src/livepeer_gateway/live_runner.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/livepeer_gateway/live_runner.py

Comment thread src/livepeer_gateway/http.py
Comment on lines +328 to +339
try:
resp = await session.request(resolved_method, url, data=body, headers=req_headers)
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
await session.close()
raise LivepeerGatewayError(
f"HTTP stream error: failed to reach endpoint: {getattr(e, 'message', e)} (url={url})"
) from e
if resp.status >= 400:
raw = await resp.text()
resp.release()
await session.close()
_raise_http_json_error(resp.status, url, raw, dict(resp.headers.items()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Session/response can leak if the error-body read fails.

In the resp.status >= 400 branch, raw = await resp.text() isn't guarded — if reading the error body itself raises (e.g. connection drop mid-read), resp.release()/session.close() never run and the session leaks. Separately, session.request(...) at line 329 is only guarded for aiohttp.ClientError/asyncio.TimeoutError; any other unexpected exception during the request also bypasses session.close(), unlike request_json's documented catch-all handling of unexpected failures.

🧹 Proposed fix: guarantee cleanup with try/finally
     if resp.status >= 400:
-        raw = await resp.text()
-        resp.release()
-        await session.close()
+        try:
+            raw = await resp.text()
+        finally:
+            resp.release()
+            await session.close()
         _raise_http_json_error(resp.status, url, raw, dict(resp.headers.items()))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/livepeer_gateway/http.py` around lines 328 - 339, Update the request flow
around session.request and the resp.status >= 400 branch to guarantee both
response release and session closure via try/finally, including when reading
resp.text() fails. Broaden request exception handling to clean up and preserve
the documented catch-all behavior for unexpected exceptions, while retaining the
existing LivepeerGatewayError conversion and HTTP JSON error handling.

Comment thread src/livepeer_gateway/http.py
Comment thread src/livepeer_gateway/http.py
Comment thread src/livepeer_gateway/http.py
Comment thread tests/test_media_publish.py Fixed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (7)
tests/test_media_publish.py (1)

730-730: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unused unpacked segment. Rename to _segment to silence Ruff RUF059.

♻️ Proposed tweak
-        media, segment = self._build_drain_media(fail_after=1)
+        media, _segment = self._build_drain_media(fail_after=1)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_media_publish.py` at line 730, In the test setup using
_build_drain_media(fail_after=1), rename the unused unpacked segment variable
from segment to _segment to satisfy Ruff RUF059 while preserving the media value
and test behavior.

Source: Linters/SAST tools

tests/test_stats_pull.py (2)

216-226: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Class-level instances registries leak across tests. Only two tests clear them (Lines 564, 894) before asserting len(instances) == 1; any future test that instantiates these fakes without clearing will break those assertions. An autouse fixture or pytest.fixture that clears both lists would make this robust. Ruff also flags RUF012 here.

Also applies to: 262-272

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_stats_pull.py` around lines 216 - 226, Update the
_TrackingPacketDemuxer and corresponding fake class registries to avoid
class-level mutable defaults flagged by RUF012, and add an autouse pytest
fixture that clears both instances lists before each test. Remove reliance on
individual test cleanup while preserving the existing length assertions.

Source: Linters/SAST tools


467-474: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace hand-rolled module-attribute swaps with mock.patch.object. The original_x = mod.X; mod.X = fake; try/finally: mod.X = original_x pattern is repeated ~10 times; with mock.patch.object(media_output_mod, "MpegTsDecoder", lambda: _FakeDecoder([...])): (or pytest's monkeypatch) removes the boilerplate and the # type: ignore noise, and is restore-safe by construction.

Also applies to: 508-515, 532-540, 548-556, 565-572, 619-626, 661-668, 720-727, 895-902, 958-970, 1177-1192

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_stats_pull.py` around lines 467 - 474, Replace the manual
save/assign/try-finally restoration pattern around MpegTsDecoder and the other
repeated module-attribute swaps in the affected tests with mock.patch.object
context managers (or pytest monkeypatch). Keep each test’s existing fake
implementation and assertions unchanged while removing the original-value
variables, explicit restoration, and associated type-ignore comments.
tests/test_live_payment_session.py (1)

16-19: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Clear the signer-info cache before the test too. Currently only teardown clears it, so a cached entry populated by another module (get_signer_info is process-global) can make test_get_signer_info_caches_result observe zero calls and fail.

♻️ Proposed tweak
     `@pytest.fixture`(autouse=True)
     def clear_signer_info_cache(self):
+        get_signer_info.cache_clear()
         yield
         get_signer_info.cache_clear()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_live_payment_session.py` around lines 16 - 19, Update the
clear_signer_info_cache fixture to call get_signer_info.cache_clear() before
yielding as well as during teardown, ensuring each test starts with an empty
process-global signer-info cache while preserving cleanup afterward.
tests/test_websocket_example.py (1)

13-17: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Load the example module lazily so missing example deps skip instead of breaking collection. spec.loader.exec_module runs at import time, so any optional dependency (e.g. a websocket client) or side effect in examples/ping-pong/runner.py turns into a collection error for the whole session rather than a skipped test.

♻️ Proposed refactor
-spec = importlib.util.spec_from_file_location("websocket_runner_example", RUNNER_PATH)
-assert spec is not None
-runner = importlib.util.module_from_spec(spec)
-assert spec.loader is not None
-spec.loader.exec_module(runner)
+@pytest.fixture(scope="module")
+def runner():
+    spec = importlib.util.spec_from_file_location(
+        "websocket_runner_example", RUNNER_PATH
+    )
+    assert spec is not None and spec.loader is not None
+    module = importlib.util.module_from_spec(spec)
+    try:
+        spec.loader.exec_module(module)
+    except ImportError as exc:
+        pytest.skip(f"ping-pong example unavailable: {exc}")
+    return module

Then take runner as a test argument in both test methods.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_websocket_example.py` around lines 13 - 17, Move the
example-module loading currently performed at test module scope into a pytest
fixture, importing it lazily via spec.loader.exec_module and skipping when
optional dependencies are unavailable. Update both test methods to accept the
fixture-provided runner instead of relying on the global runner object, while
preserving their existing assertions.
tests/test_control_keepalive.py (1)

72-78: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Patching control_mod.asyncio.sleep mutates the global asyncio module. control_mod.asyncio is the stdlib module object, so this replaces asyncio.sleep process-wide for the duration of the block; any other coroutine that sleeps during that window will block on permits. It works today because these tests are serial, but it is fragile under -p xdist/parallel loops. Prefer injecting the sleep function into Control (or exposing a module-level _sleep alias in control.py) and patching that instead. Same pattern at Lines 106 and 131.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_control_keepalive.py` around lines 72 - 78, Stop patching
control_mod.asyncio.sleep in the keepalive tests because it mutates the shared
stdlib asyncio module; introduce a Control-level sleep dependency or
module-level _sleep alias in control.py, use it from the keepalive
implementation, and update the patches at the referenced test cases (including
the occurrences around lines 106 and 131) to target that isolated symbol
instead.
pyproject.toml (1)

39-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Set asyncio_default_fixture_loop_scope explicitly. With pytest-asyncio ≥0.24 in auto mode, leaving this unset emits a deprecation warning when async fixtures use loop-scoped execution; setting it now keeps test output clean and future-proofs the event-loop scope.

♻️ Proposed tweak
 asyncio_mode = "auto"
+asyncio_default_fixture_loop_scope = "function"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pyproject.toml` around lines 39 - 46, Update the [tool.pytest.ini_options]
configuration to set asyncio_default_fixture_loop_scope explicitly, choosing the
intended fixture event-loop scope and keeping it consistent with the existing
asyncio_mode = "auto" behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/tests.yml:
- Line 16: Update the actions/checkout step in the tests workflow to set
persist-credentials to false, ensuring the checkout token is unavailable to
subsequent pytest and test steps.

In `@tests/test_decode_metrics_sim.py`:
- Around line 1-8: Add the missing livepeer_gateway.decode_metrics_sim module
exposing _actual_decoder_snapshot and simulate_decoder_metric_drift so
tests/test_decode_metrics_sim.py imports successfully; implement the expected
simulation behavior used by the test, and make its timing or drift calculation
deterministic enough to preserve the max_abs_drift bounds without relying on
wall-clock scheduling.

In `@tests/test_multi_track_verify.py`:
- Line 6: Provide the missing livepeer_gateway.multi_track_verify module or
update the test import to the correct existing module. Ensure the module exposes
_goertzel_power, _verify_audio_track, _match_video_tracks, default_audio_specs,
default_video_specs, ObservedAudioTrack, ObservedVideoTrack, and
VideoFrameObservation so tests can collect without ImportError.

In `@tests/test_stats_pull.py`:
- Around line 1094-1109: Update
test_decoder_output_wait_metrics_accumulate_blocked_get_time to remove the tight
output_wait_s upper-bound assertion, retaining the >= 0.02 lower bound that
verifies blocked wait time accumulation.

In `@tests/test_trickle_shutdown_races.py`:
- Around line 154-155: Update the pytest.raises call around publisher.next() to
use a raw string for the regular-expression match pattern, preserving the
existing “closed|closing” alternatives and exception assertion.

---

Nitpick comments:
In `@pyproject.toml`:
- Around line 39-46: Update the [tool.pytest.ini_options] configuration to set
asyncio_default_fixture_loop_scope explicitly, choosing the intended fixture
event-loop scope and keeping it consistent with the existing asyncio_mode =
"auto" behavior.

In `@tests/test_control_keepalive.py`:
- Around line 72-78: Stop patching control_mod.asyncio.sleep in the keepalive
tests because it mutates the shared stdlib asyncio module; introduce a
Control-level sleep dependency or module-level _sleep alias in control.py, use
it from the keepalive implementation, and update the patches at the referenced
test cases (including the occurrences around lines 106 and 131) to target that
isolated symbol instead.

In `@tests/test_live_payment_session.py`:
- Around line 16-19: Update the clear_signer_info_cache fixture to call
get_signer_info.cache_clear() before yielding as well as during teardown,
ensuring each test starts with an empty process-global signer-info cache while
preserving cleanup afterward.

In `@tests/test_media_publish.py`:
- Line 730: In the test setup using _build_drain_media(fail_after=1), rename the
unused unpacked segment variable from segment to _segment to satisfy Ruff RUF059
while preserving the media value and test behavior.

In `@tests/test_stats_pull.py`:
- Around line 216-226: Update the _TrackingPacketDemuxer and corresponding fake
class registries to avoid class-level mutable defaults flagged by RUF012, and
add an autouse pytest fixture that clears both instances lists before each test.
Remove reliance on individual test cleanup while preserving the existing length
assertions.
- Around line 467-474: Replace the manual save/assign/try-finally restoration
pattern around MpegTsDecoder and the other repeated module-attribute swaps in
the affected tests with mock.patch.object context managers (or pytest
monkeypatch). Keep each test’s existing fake implementation and assertions
unchanged while removing the original-value variables, explicit restoration, and
associated type-ignore comments.

In `@tests/test_websocket_example.py`:
- Around line 13-17: Move the example-module loading currently performed at test
module scope into a pytest fixture, importing it lazily via
spec.loader.exec_module and skipping when optional dependencies are unavailable.
Update both test methods to accept the fixture-provided runner instead of
relying on the global runner object, while preserving their existing assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 73d9f912-6504-4b40-9eb7-3b78e6919415

📥 Commits

Reviewing files that changed from the base of the PR and between 9f2bc20 and 533a2ad.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (19)
  • .github/workflows/tests.yml
  • README
  • pyproject.toml
  • src/livepeer_gateway/media_publish.py
  • tests/test_byoc_training.py
  • tests/test_channel_reader.py
  • tests/test_control_keepalive.py
  • tests/test_decode_metrics_sim.py
  • tests/test_discovery.py
  • tests/test_live_payment_session.py
  • tests/test_live_runner.py
  • tests/test_media_publish.py
  • tests/test_multi_track_verify.py
  • tests/test_selection.py
  • tests/test_start_scope.py
  • tests/test_stats_pull.py
  • tests/test_token.py
  • tests/test_trickle_shutdown_races.py
  • tests/test_websocket_example.py
💤 Files with no reviewable changes (1)
  • tests/test_byoc_training.py

pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow excerpt =="
if [ -f .github/workflows/tests.yml ]; then
  cat -n .github/workflows/tests.yml
else
  echo ".github/workflows/tests.yml not found"
fi

echo
echo "== checkout usages =="
rg -n "actions/checkout|persist-credentials|pytest|pull_request|permissions:" .github/workflows || true

echo
echo "== workflow files list =="
git ls-files .github/workflows

Repository: livepeer/livepeer-python-gateway

Length of output: 1465


🌐 Web query:

actions/checkout default persist-credentials true README

💡 Result:

In the actions/checkout GitHub Action, the persist-credentials input controls whether the authentication token or SSH key is configured in the local git configuration [1][2][3]. When set to true (which remains the default), the credentials are made available to subsequent steps in the job to allow authenticated git operations [1][2]. The action automatically removes these credentials during the post-job cleanup phase [1][2]. Starting with version 6.0.0, the action improved security by storing these credentials in a separate file under the $RUNNER_TEMP directory instead of writing them directly into the.git/config file [2][4]. While there has been community discussion and proposals to change the default value of persist-credentials to false to minimize security risks [5][6], the default value remains true in the current official documentation and action configuration [2][3]. Users who wish to disable this behavior and prevent credentials from being available to other steps in their workflow can explicitly set persist-credentials: false [1][2].

Citations:


Disable checkout credential persistence

This workflow runs pull-request code via pytest and does not need authenticated Git operations after checkout. Set persist-credentials: false so the checkout token is not made available to later test steps.

Proposed fix
-      - uses: actions/checkout@v7
+      - uses: actions/checkout@v7
+        with:
+          persist-credentials: false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- uses: actions/checkout@v7
- uses: actions/checkout@v7
with:
persist-credentials: false
🧰 Tools
🪛 zizmor (1.28.0)

[warning] 16-16: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/tests.yml at line 16, Update the actions/checkout step in
the tests workflow to set persist-credentials to false, ensuring the checkout
token is unavailable to subsequent pytest and test steps.

Source: Linters/SAST tools

Comment thread tests/test_decode_metrics_sim.py Outdated
Comment thread tests/test_multi_track_verify.py Outdated
import math
from array import array

from livepeer_gateway import multi_track_verify as verify_mod

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -i 'multi_track_verify*' .
rg -n 'multi_track_verify' --glob '!tests/test_multi_track_verify.py'
rg -n '_goertzel_power|default_audio_specs|ObservedAudioTrack|_match_video_tracks' -g '!tests/**'

Repository: livepeer/livepeer-python-gateway

Length of output: 205


🏁 Script executed:

#!/bin/bash
set -e

echo "== file listing =="
git ls-files | sed -n '1,200p'

echo "== test import and referenced symbols =="
cat -n tests/test_multi_track_verify.py

echo "== package files and livepeer_gateway references =="
fd -i 'livepeer_gateway' . || true
rg -n 'livepeer_gateway|multi_track_verify|package|packages' pyproject.toml setup.py setup.cfg poetry.lock uv.lock Pipfile requirements*.txt tests/test_multi_track_verify.py || true

Repository: livepeer/livepeer-python-gateway

Length of output: 50389


Add the missing multi_track_verify package module. tests/test_multi_track_verify.py imports livepeer_gateway.multi_track_verify, but only the test file exists; no matching module or referenced helper symbols are present, so collection fails with ImportError. Add src/livepeer_gateway/multi_track_verify.py with _goertzel_power, _verify_audio_track, _match_video_tracks, default_audio_specs, default_video_specs, ObservedAudioTrack, ObservedVideoTrack, and VideoFrameObservation, or point the import at the correct package module.

🧰 Tools
🪛 GitHub Actions: Tests / 0_pytest.txt

[error] 6-6: Pytest import error during test collection: ImportError: cannot import name 'multi_track_verify' from 'livepeer_gateway' ('/home/runner/work/livepeer-python-gateway/livepeer-python-gateway/src/livepeer_gateway/init.py').

🪛 GitHub Actions: Tests / pytest

[error] 6-6: Pytest failed during test collection due to import error: ImportError: cannot import name 'multi_track_verify' from 'livepeer_gateway' (/home/runner/work/livepeer-python-gateway/livepeer-python-gateway/src/livepeer_gateway/init.py).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_multi_track_verify.py` at line 6, Provide the missing
livepeer_gateway.multi_track_verify module or update the test import to the
correct existing module. Ensure the module exposes _goertzel_power,
_verify_audio_track, _match_video_tracks, default_audio_specs,
default_video_specs, ObservedAudioTrack, ObservedVideoTrack, and
VideoFrameObservation so tests can collect without ImportError.

Source: Pipeline failures

Comment thread tests/test_stats_pull.py
Comment on lines +1094 to +1109
def test_decoder_output_wait_metrics_accumulate_blocked_get_time(self) -> None:
decoder = MpegTsDecoder()

def _put_later() -> None:
time.sleep(0.03)
decoder._put_output_item(object())

producer = threading.Thread(target=_put_later, daemon=True)
producer.start()
got = decoder.get()
producer.join()

assert got is not None
stats = decoder.get_stats()
assert stats.output_wait_s >= 0.02
assert stats.output_wait_s < 0.25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Wall-clock assertion may be flaky under load. output_wait_s < 0.25 after a 0.03s sleep is tight for a loaded CI runner where the producer thread can be descheduled. Consider dropping the upper bound (or widening it substantially) and keeping only the >= 0.02 lower bound, which is what the metric actually needs to prove.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_stats_pull.py` around lines 1094 - 1109, Update
test_decoder_output_wait_metrics_accumulate_blocked_get_time to remove the tight
output_wait_s upper-bound assertion, retaining the >= 0.02 lower bound that
verifies blocked wait time accumulation.

Comment on lines +154 to +155
with pytest.raises(RuntimeError, match="closed|closing"):
await publisher.next()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use a raw string for the regex. Ruff (RUF043) flags the unescaped alternation; the intent is a regex, so mark it raw.

🩹 Proposed fix
-        with pytest.raises(RuntimeError, match="closed|closing"):
+        with pytest.raises(RuntimeError, match=r"closed|closing"):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
with pytest.raises(RuntimeError, match="closed|closing"):
await publisher.next()
with pytest.raises(RuntimeError, match=r"closed|closing"):
await publisher.next()
🧰 Tools
🪛 Ruff (0.16.0)

[warning] 154-154: Pattern passed to match= contains metacharacters but is neither escaped nor raw

(RUF043)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_trickle_shutdown_races.py` around lines 154 - 155, Update the
pytest.raises call around publisher.next() to use a raw string for the
regular-expression match pattern, preserving the existing “closed|closing”
alternatives and exception assertion.

Source: Linters/SAST tools

Comment thread tests/test_decoder_queue_metrics.py Fixed
Comment thread tests/test_decoder_queue_metrics.py Fixed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/test_decoder_queue_metrics.py (1)

149-167: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Replace getattr with direct attribute access.

getattr(decoder, "_reader"), getattr(reader, "_queue"), getattr(decoder, "_output"), and getattr(reader, "_buffer") use a constant attribute name. Direct attribute access is equivalent and clearer. Ruff flags all four calls (B009).

♻️ Proposed refactor
 def _actual_decoder_snapshot(decoder: object) -> tuple[int, int, int, int]:
-    reader = getattr(decoder, "_reader")
-    input_queue = getattr(reader, "_queue")
+    reader = decoder._reader
+    input_queue = reader._queue
     with input_queue.mutex:
         input_items = list(input_queue.queue)
     queued_payloads = [
         item
         for item in input_items
         if isinstance(item, (bytes, bytearray, memoryview))
     ]
-    output_queue = getattr(decoder, "_output")
+    output_queue = decoder._output
     with output_queue.mutex:
         output_items_queued = len(output_queue.queue)
     return (
         len(queued_payloads),
         sum(len(item) for item in queued_payloads),
-        len(getattr(reader, "_buffer")),
+        len(reader._buffer),
         output_items_queued,
     )

As per static analysis hints, Ruff flags getattr with a constant attribute value at Lines 150, 151, 159, and 165 (B009).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_decoder_queue_metrics.py` around lines 149 - 167, Update
_actual_decoder_snapshot to replace the four constant-name getattr calls with
direct attribute access on decoder and reader: _reader, _queue, _output, and
_buffer. Preserve the existing queue locking, filtering, and snapshot
calculations.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/test_decoder_queue_metrics.py`:
- Around line 149-167: Update _actual_decoder_snapshot to replace the four
constant-name getattr calls with direct attribute access on decoder and reader:
_reader, _queue, _output, and _buffer. Preserve the existing queue locking,
filtering, and snapshot calculations.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c640a1e8-2c98-446e-b181-efa7cdea3c5d

📥 Commits

Reviewing files that changed from the base of the PR and between 533a2ad and dfee3ce.

📒 Files selected for processing (3)
  • tests/test_decoder_queue_metrics.py
  • tests/test_live_payment_session.py
  • tests/test_media_publish.py

Comment thread src/livepeer_gateway/live_runner.py Fixed
Comment thread src/livepeer_gateway/live_runner.py Fixed
Comment thread src/livepeer_gateway/channel_reader.py Fixed
Comment thread src/livepeer_gateway/live_runner.py Fixed
seanhanca and others added 25 commits July 31, 2026 14:08
…ge into ja/live-runner (#46)

## Why
The production SDK (`sdk-service:byoc-dual-path-1bf13cd`) carries a
**load-bearing byoc-payment fix that exists in no branch** — only in the
running container. That's an operational liability and it blocks a
unified gateway. This ports it onto `ja/live-runner` (the Live Runner +
Scope branch, PR #20 → main) so the consolidated gateway keeps BYOC
payment working while gaining LR.

## What
- `_payment_type_for_signer(signer_url)` — **legacy Daydream signer**
(`signer.daydream.live`) → `type:"lv2v"` + string `capability`; **modern
signers** (pymthouse DMZ, …) → `type:"byoc"` + BYOC capabilities
protobuf.
- `_create_byoc_payment` — assemble the payment payload + orch-discovery
capabilities per the resolved type.
- `capabilities.py` — `CapabilityId.BYOC` +
`byoc_capabilities_from_app()`.

Two files, +52/−9. Byte-identical to what runs in prod today.

## Relationship to #41
PR #41 sends `type:"byoc"` **unconditionally** and depends on an
undeployed go-livepeer signer+orch change. Against
`signer.daydream.live` (which only accepts `lv2v` today) that reproduces
the **2026-07-13 “invalid job type” outage**. This PR is the
**superset**: per-signer switching keeps the legacy signer working *and*
enables modern signers. Recommend closing #41 in favor of this.

## Follow-on
The same per-signer type logic generalizes to the **live-runner**
payment path, which will let us drop the `lr-gateway` `lv2v` workaround
once this lands.

## Test
- [ ] `python -m py_compile` (passes locally)
- [ ] `submit_byoc_job` against `signer.daydream.live` → `type:lv2v` →
200 (no regression)
- [ ] `submit_byoc_job` against a modern signer → `type:byoc` + caps
proto → 200

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ibling to #46) (#47)

Sibling to #46. #46 gives the **BYOC** payment path a per-signer
dual-path; this gives the **Live Runner** payment path the same, so
selecting an LR runner doesn't break payment on the default (Daydream)
signer.

## The bug
`_get_runner_payment` (live_runner.py) hardcoded `type="live"` for every
non-scope runner. `signer.daydream.live` only accepts `lv2v` → **`400
invalid job type`** → LR payment fails. The `lr-gateway` `lv2v` patch
only fixed the isolated sidecar; the gateway itself was still broken for
LR + Daydream. Without this, the transparent-routing plan's **Scenario 1
(Daydream + LR) breaks**.

## The fix
- Canonical `_payment_type_for_signer` moved to **`remote_signer.py`**
(the module that owns `LivePaymentSession`) so **both** payment paths
share it and LR does **not** import the soon-deprecated `byoc.py`.
Legacy Daydream → `lv2v`; modern signers → `byoc`.
- `_get_runner_payment`: Scope/LV2V stays `lv2v`; every other runner
uses the per-signer switch.

Two files, +28/−2. Both compile.

## Verified
Generalizes the exact fix proven on-chain via the lr-gateway `lv2v`
patch: LR single-shot + Daydream signer → `Payment tickets processed,
totalTickets=1` on Arbitrum.

## Follow-up
Once #46 merges, `byoc.py`'s local `_payment_type_for_signer` copy
should import this canonical one (trivial dedup) — the two PRs touch
disjoint files so they merge without conflict.

## Test
- [ ] `py_compile` (passes locally)
- [ ] LR + Daydream signer → `type:lv2v` → ticket redeems (the
Scenario-1 gate)
- [ ] LR + modern signer → `type:byoc` (gated on the pymthouse upstream
fix + orch byoc-single-shot verification)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…prod image into ja/live-runner (#46)"

This reverts commit 4e46379.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Content-type detection was a substring test (`"json" in content_type`),
which is right for `+json` types only by accident and wrong for
multi-document formats: `application/jsonl` (this repo's own trickle
channel default), `application/x-ndjson`, and `application/json-seq`
all matched and then failed in json.loads, so a working runner fronting
a streaming API got "did not return valid JSON" instead of its bytes —
the same bug class this branch set out to fix.

Match on the media subtype instead, via aiohttp's own mimetype parser:
`application/json` or the RFC 6839 `+json` structured suffix. Vendor
types (`application/vnd.acme.v1+json`) keep parsing without being
listed; multi-document bodies fall through to `raw`. Only four
classifications change, all previously raising.

Also fold the raw early return into the single existing return, so
`payment_session=None if payment_type == "fixed" else payment_session`
stays in one place — payment-type handling here has been reverted twice
and there is no paid-binary test to catch the two sites drifting. The
`session_id` expression reduces to the early return's behavior when
`data` is empty, so this is equivalent.

Add `content_type` to the JSON parse error: it is the fact that routed
the response into parsing, and without it a misclassification is
undebuggable from the message alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`content_type` is self-documenting from the field name; the invariant
worth stating is that a populated `raw` means an empty `data`. The
subtype helper matches its bare neighbors in this module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`raw` already means "the original JSON dict, un-normalized" on three
dataclasses in this SDK — LiveRunnerSessionEvent.raw, LiveRunnerInstance.raw,
LiveVideoToVideo.raw — and examples dump it with json.dumps(x.raw). A
`raw: Optional[bytes]` on LiveRunnerCallResult overloads the name to mean the
opposite, sharply so on one expression chain: `result.raw` (bytes) sitting
one dot from `result.runner.raw` (dict).

`content` matches its sibling `content_type` and the ecosystem convention for
a response body as bytes (requests/httpx `.content`) — where `.raw` instead
means an unread stream object, so the old name actively misled. Nothing
consumes the field yet (runner-app-examples#45 and api-proxy both use the
streaming path), so this is free now and permanent once released.

Semantics unchanged: still `Optional[bytes] = None`, since `b""` is a valid
empty body and `None` is the only unambiguous "this was JSON" sentinel. Also
renames the local `raw` to `body` — it holds bytes, while `raw` in this
codebase reads as a dict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One helper builds every SDK request and hardcodes Accept:
application/json. That is right for the control plane, where the
orchestrator is a JSON API, but call_runner is the one function whose
destination is arbitrary app code. An upstream that content negotiates
then answers in JSON because the SDK asked: the api-proxy example
advertises raw JPEG bytes and Hugging Face returned a base64 PNG in a
JSON string, so the client wrote base64 text to a .jpg.

App calls now state no preference and let the app pick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@j0sh
j0sh force-pushed the ja/live-runner branch from d2d84f0 to 0fa8963 Compare July 31, 2026 21:08
@j0sh

j0sh commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

apologies for the force-push, I didn't realize updating GitHub stacked PRs would also change the base branch ... ugh

if not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
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.

3 participants